aiml
SearchMy learning
Menu

Lesson 2 of 17Module 1

Run an agent and read its event stream

Use Python Runner to manage an ADK conversation, inspect model and tool events, select the final answer and handle streaming and model-call limits.

Prerequisites

Python, a Gemini API key, and the preceding lessons in Agents and tools.

By the end, you can

  • Run an agent from Python with Runner, InMemorySessionService and run_async, and pick the answer out of the events.
  • Explain which events a turn yields, which ones the session stores, and what streaming adds.
  • Handle a missing session and the max_llm_calls limit, and know what each one leaves behind.
Runtime
Python 3.12+ and a Gemini API key
Dependencies
google-adk 2.12.0, litellm and python-dotenv

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

Progress is saved in this browser.

When you need this. Terminal chat is enough to try an agent. A documentation portal needs application code that receives a question, manages the conversation and decides which response to display. We will give our docuctl assistant a Python Runner and inspect the events it returns.

The assistant now has a read_page tool. Instead of sending every document in its instruction, it asks for the page it needs. This produces a useful sequence to inspect: a user message, a tool call, a tool result and an answer.

data flowreturnartifact hand-offfailureask.pyrun_async loop,answer()Runnersession, history,RunConfig: streaming_mode,max_llm_callsmessageeventsgemini-3.8-flashhistorycall or textread_pageargsresultcall limitLlmCallsLimit…InMemorySessionServicenon-partial events, stateappendunknown sessionSessionNotFound…data flowreturnartifact hand-offfailureask.pyrun_async loopmessageeventsRunnersession, history,RunConfigaskreplyGeminiargsresultread_pagecall limitexceededappendsession servicenon-partial eventsunknown sessionSessionNotFoundError
The Runner yields model and tool events to the application. The session stores complete events; partial streamed chunks go only to the caller.

Give the assistant a tool to read pages

Download the lab using Download code, extract it, and open adk-event-stream-lesson. Use Python 3.12 or later. The lab’s docuctl pages describe a fictional CLI; you will read documentation, not execute a deployment.

Prepare the event-stream projectTerminal · bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
cp .env.example .env

Set your Gemini key in the private .env. ask.py loads it before importing the agent; exported shell values take precedence. The same root .env convention is used by the remaining labs in this module.

First, read the page the assistant will use:

docs/rollback.mdSource · markdownDownload
# Roll back a release

`docuctl rollback` moves an environment back to an earlier release. It does not rebuild anything; it points traffic at the release you name.

    docuctl rollback --env production --to r41

Without `--to`, docuctl rolls back to the previous release. A rollback is itself recorded as a new release, so `docuctl releases` shows it.

Database migrations are not rolled back. If the release you leave ran a migration, write a new migration that reverses it.

docuctl 1.x used `docuctl release revert <release>`. That command was removed in docuctl 2.0.

Then attach a Python function that reads one of the four bundled pages:

docs_assistant/agent.pySource · pythonDownload
"""The docs assistant with one tool, so its event stream has calls, results and an answer."""
import os
from pathlib import Path

from google.adk import Agent
from google.adk.models.lite_llm import LiteLlm
from google.genai import types

# Gemini through ADK's native client; set DOCS_MODEL=ollama_chat/gemma4:e2b to run Gemma locally.
MODEL = os.environ.get("DOCS_MODEL", "gemini-3.8-flash")
DOCS = Path(__file__).resolve().parent.parent / "docs"
PAGES = sorted(p.stem for p in DOCS.glob("*.md"))


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

    Args:
        page: The page name: config, deploy, rollback or tokens.
    """
    if page not in PAGES:
        return {"error": f"No page {page!r}. Pages: {', '.join(PAGES)}."}
    return {"page": page, "text": (DOCS / f"{page}.md").read_text(encoding="utf-8")}


root_agent = Agent(
    name="docs_assistant",
    model=LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL,
    description="Answers developer questions about docuctl from its documentation.",
    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.
Copy commands exactly, in backticks. Keep the answer under 100 words.""",
    tools=[read_page],
    generate_content_config=types.GenerateContentConfig(
        temperature=0.2,
        max_output_tokens=1024,
        thinking_config=types.ThinkingConfig(thinking_level="low"),
    ),
)

ADK derives the tool declaration from the function’s signature and docstring. The model proposes read_page(page="rollback"); Python reads that file and returns its text. A model turn can then use the result to answer. The agent requests LOW thinking and a maximum of 1,024 output tokens per response.

Run the agent with a Runner

InMemorySessionService stores the messages and state for each conversation. Runner connects that service to an agent. Create a session before calling run_async, and pass its ID with every new message.

The script’s entrypoint makes those choices explicitly:

ask.py — create a session and configure the runSource · pythonDownload
async def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("question")
    parser.add_argument("--stream", action="store_true", help="stream text chunks as partial events")
    parser.add_argument("--max-llm-calls", type=int, default=10)
    args = parser.parse_args()

    sessions = InMemorySessionService()
    runner = Runner(app_name=APP, agent=root_agent, session_service=sessions)
    session = await sessions.create_session(app_name=APP, user_id=USER)
    config = RunConfig(streaming_mode=StreamingMode.SSE if args.stream else StreamingMode.NONE,
                       max_llm_calls=args.max_llm_calls)
    try:
        events = await ask(runner, session.id, args.question, config)
    finally:
        await runner.close()
    for event in events:
        print(describe(event))
    stored = await sessions.get_session(app_name=APP, user_id=USER, session_id=session.id)
    print(f"yielded {len(events)} events, session stores {len(stored.events)}")
    print("answer:", answer(events))

RunConfig applies to this invocation. Here it chooses streaming and a model-call limit. The runner closes in finally, including when an invocation raises an error.

Run one question from the lab directory:

Ask a question and print its eventsTerminal · bash
python ask.py "How do I undo the last production deploy?" --max-llm-calls 2

The first three event lines in the supplied saved example are:

Recorded example. The archived “Run an agent and read its event stream” demonstration retains the earlier shipctl scenario; the current lab uses docuctl and does not claim a new model result.

Saved example — user message, tool call and tool resultRecorded output · text
user            final    'How do I undo the last production deploy?'
docs_assistant  event    call read_page({'page': 'rollback'})
docs_assistant  event    result read_page -> ['page', 'text']

The final event in the original recording carries the answer. Check that it describes shipctl rollback --env production and preserves the page’s warning about database migrations. Run completion alone cannot tell you whether the suggested command is correct.

An unknown session ID. Passing an ID that the session service cannot find raises SessionNotFoundError before the turn starts. Create the session first, or deliberately configure automatic creation for your application.

The max_llm_calls limit. With a limit of one, the model can request read_page and the tool can run, but the next model call raises LlmCallsLimitExceededError. A call limit does not undo a tool’s side effects. If a future tool writes data, a retry must account for the write already having happened.

Pick the answer out of the stream

run_async yields Event objects. Our ask() function collects them; answer() selects the last final response from an author other than user:

ask.py — collect events and select the answerSource · pythonDownload
async def ask(runner: Runner, session_id: str, question: str, config: RunConfig) -> list[Event]:
    """Send one user message and collect the events of that invocation, in order."""
    message = types.Content(role="user", parts=[types.Part(text=question)])
    events = []
    async for event in runner.run_async(user_id=USER, session_id=session_id, new_message=message,
                                        run_config=config, yield_user_message=True):
        events.append(event)
    return events


def answer(events: list[Event]) -> str:
    """The answer is the text of the agent's last final response, not every text chunk.

    is_final_response() is also True for the user's own message, so filter by author.
    """
    final = [e for e in events if e.is_final_response() and e.author != "user" and e.content]
    return "".join(p.text or "" for p in final[-1].content.parts if not p.thought) if final else ""

yield_user_message=True also yields the user’s stored message. That message can satisfy is_final_response(), because it is neither a partial response nor a tool call. The author check keeps the question from becoming its own answer.

The user’s message counted as the answer. Taking the first event for which is_final_response() is true can return the question. Check the author and use the agent’s final response.

Try the streaming variant:

Compare partial events with the completed responseTerminal · bash
python ask.py "How do I undo the last production deploy?" --stream --max-llm-calls 2

Streamed text shown twice. Partial events carry chunks, and the final event can carry the complete text again. A UI should render chunks as they arrive, then replace that draft with the final text. Printing both as new text duplicates the answer. This script collects the events before printing them so you can inspect their order; it is not a live-streaming UI.

Follow model and tool events

Use author to identify the speaker and invocation_id to group events from one turn. Tool-call and tool-response parts share an ID, which lets you match a result to its call. In serialized JSON these names appear as invocationId, functionCall and functionResponse.

The call event comes from the model. The tool-result event has the agent’s author name but user-role content, so Gemini receives the function result on the input side of the conversation. ADK then sends that history into the next model request.

In the supplied streaming example, partial events were yielded to the caller but were not appended to the session. Inspect the script’s final yielded ... events, session stores ... line to compare the two lists. Keep provider-managed fields intact when passing stored events back to the model.

You can also open the package with adk web --host 127.0.0.1 --port 8000 ., select docs_assistant, and inspect the tool call in Events. It uses a separate session; it does not run the event-printing code in ask.py.

Test the runner without a model

Run the offline event testsTerminal · bash
python -m unittest -v test_ask.py

The tests feed saved model responses through the real Runner, tool and session service. They check answer selection, partial-event storage, missing sessions and the call-limit failure without making a provider request.

Why does answer() check both is_final_response() and the event author?

Choose one answer, then check your reasoning.

Read the explanation

A final event is not necessarily an assistant answer. Check the author, content and final-response status.

Serve many conversations from one runner

Keep one Runner per application process and give each conversation its own session. Serialize turns within a session so two requests do not append competing histories. In-memory sessions disappear when the process exits; the state-and-memory lesson replaces that service with SQLite.

Next, give the assistant typed function tools and validate proposed arguments before a write.

References

Written by Arjun Prabhulal.

Module 1

Agents and tools