Skip to content

2.0. Concepts

In one glance

  • You will: Learn the five pieces every ADK agent has — agent, runner, session, events, tools — and what the framework deliberately leaves for you to write.
  • You need: Nothing beyond a terminal and the repository cloned.
  • Time: about 14 minutes, concept.

What does ADK provide?

Google ADK is the application runtime around a model. The model proposes behavior; your code defines the capabilities and policy that are allowed to execute. That split is the whole mental model. Everything else on this page is either something the model asks for or something your runtime does about it.

Concretely, ADK supplies agent definitions, tool schemas and execution, callbacks, conversation sessions, workflow graphs, events, evaluation helpers, and A2A serving. A2A is a protocol for discovering agents and exchanging tasks across process boundaries (0.7. Glossary).

It also supplies the seam that decides where policy attaches, and it is worth learning early because it is easy to get wrong. There are two levels:

Level What it is Use it for
Agent(..., *_callback=...) Hooks on one agent Behavior genuinely specific to that agent
App(plugins=[...]) A BasePlugin whose hooks fire for every agent it runs Cross-cutting policy: budgets, redaction, output hardening, error handling

This course puts all of its policy on an App plugin (4.5. Guardrails). The rule generalizes: if a rule should hold for every agent you will ever add, attaching it per agent makes it a thing to remember, and something you must remember is not a guarantee.

An Agent declaration is configuration, not a network call:

# simplified
from google.adk import Agent

from agent.model import build_model

agent = Agent(
    name="example",
    model=build_model(),
    instruction="Answer only from registered tools.",
    tools=[],
)

Constructing that object opens no socket and spends no tokens. build_model() resolves the provider client (native Gemini by default, optional Ollama). Inference only begins once a runner drives a message through it. This page is the map; the sibling pages own the territory.

What does the runner own?

A runner turns one message into one completed turn. It is the operational boundary where sessions, callbacks, tools, and the model meet — a far more important boundary than the prompt string alone. Every invocation runs the same loop:

  1. Load or create the session and append the new user content.
  2. Call the agent's model.
  3. If the model requested a tool, run the tool callbacks and execute it, feed the result back, and loop.
  4. If the model emitted a final response — or the call budget is exhausted — stop and emit the terminal event.

The course does not hand-roll this loop; ADK's Runner owns it. The persistent A2A server constructs exactly one per application, wiring the served agent to the persistent session service:

# simplified
runner = Runner(app=App(name=_APP_NAME, root_agent=agent, plugins=[AgentOpsPolicyPlugin()]), session_service=session_service)

Here _APP_NAME = "agentops-agent" and agent defaults to root_agent. See server.py. Note the runner takes an App, not a bare agent: that is what carries the policy plugin, and it is why an agent injected here is governed exactly like the shipped one. The host adk run/adk web CLI builds its own runner with a development session service instead — same loop, ephemeral store.

The loop has two exits, and the second one is a control you configure:

flowchart TD
    U([User message + session id]) --> M[Model proposes]
    M --> Q{Tool requested?}
    Q -->|yes| V["Runtime validates args (before_tool),<br/>executes tool, appends result"]
    V --> B{max_llm_calls reached?}
    B -->|no| M
    B -->|yes| F
    Q -->|no · final response| F([Emit terminal event])
Deeper: how does the A2A server set that bound?

On the A2A path that budget is explicit. _bounded_request in server.py rewrites each request's RunConfig so max_llm_calls = AGENT_A2A_MAX_LLM_CALLS (default 12, validated 1–100), replacing ADK's broad default so a model that keeps calling tools cannot spin unbounded. 3.6. A2A covers the bound in full; note it caps model calls, not wall-clock time or cost — those are separate controls (drain timeout, token budget).

What does ADK not do for you?

ADK gives you seams — places where your own code attaches — not a finished operations posture. Knowing where the framework stops is exactly what the rest of the course builds on:

  1. Sessions are ephemeral by default. adk run, adk web, and the eval InMemoryRunner all use an in-memory session service, so state dies with the process. Persistence is a choice you make: only the A2A server opts into DatabaseSessionService over .state/runtime.db (2.4. Sessions).
  2. There is no built-in auth on the A2A path. The default server registers only the A2A and health routes, with no authentication. ADK synthesizes a per-context user id, so the audit trail — the record of who asked for what — names a synthetic subject rather than a verified person. A production edge must authenticate the caller and propagate that identity (3.1. Tools, and the gateway in Chapters 5–6).
  3. Policy is your code, not the framework's. Token budgets, PII redaction, argument validation, and prompt-injection defense are functions in budget.py, pii.py, and guardrails.py, bound together by the plugin in governance.py. ADK supplies the seam and runs them; it does not supply the rules.
  4. The model can only propose. ADK lets a model request any registered tool; it does not itself distinguish a read from a state change. That line is drawn by your require_confirmation=True flags and human approval, not by the framework (3.1. Tools).
  5. The loop is unbounded unless you bound it. ADK's default call budget is broad; the A2A server narrows it to AGENT_A2A_MAX_LLM_CALLS (12). It caps model calls, not cost or wall-clock time.

What is stored in a session?

A session is one conversation, keyed by application, user, and session id. It holds the event history and the small state that later turns of the same chat need. It is short-term conversation memory — not the incident database, runbook library, audit log, or cross-session notes. 3.4. Memory tabulates those five distinct stores; conflating them is the classic memory bug.

Because the model is stateless (2.2. Models), the session is the memory: whoever owns it owns what the agent can recall. 2.4. Sessions owns the persistence story — why the A2A server writes DatabaseSessionService and DatabaseTaskStore to .state/runtime.db so a process restart does not silently erase every conversation and task.

What are events?

An ADK turn is not one reply. It is a stream of typed events: model content, function calls, function responses, state deltas (writes to session state), errors, and a final response.

A correct client consumes the sequence and never assumes each event carries user-facing text. The predicates you actually reach for live on the event: event.get_function_calls(), event.get_function_responses(), event.is_final_response(), event.content, and event.error_code.

The evaluation harness reads exactly those to reconstruct both the answer and the tool trajectory from one stream:

# simplified
async for event in runner.run_async(user_id=user_id, session_id=session.id, new_message=message):
    for call in event.get_function_calls():
        if not call.name:
            continue
        recorded_call = {"name": call.name, "args": dict(call.args or {})}
        tool_calls.append(recorded_call)
        confirmation_pause = _confirmation_pause_response(recorded_call) or confirmation_pause
    if event.is_final_response() and event.content:
        answer_parts.extend(part.text for part in event.content.parts or [] if part.text)

Quoted verbatim from evals/mlflow_eval.py.

Deeper: what else reads the event stream?

The other consumer is the A2A server: _error_code_interceptor in server.py reads adk_event.error_code off intermediate events and carries it onto the terminal A2A update as adk_error_code metadata, so a structured failure such as MODEL_UNAVAILABLE (set in guardrails.py) or TOKEN_BUDGET_EXHAUSTED (set in budget.py) survives the protocol boundary instead of collapsing into a generic error.

Preserving intermediate events is what makes tracing (Ch. 7) and trajectory evaluation (Ch. 4) possible. Keep only the final string and you have discarded the evidence of how the answer was reached.

What is the difference between tools and policy hooks?

  • A tool is a capability the model may request — get_incident, search_service_logs, restart_service.
  • A policy hook is deterministic runtime policy wrapped around a model or tool boundary — a token check, PII redaction, argument validation, or stable error message.

The distinction decides who enforces a rule. A tool trusts the model to ask correctly. A policy hook does not ask the model to police itself: it can block, transform, or replace work before or after the model or a tool runs.

AgentOpsPolicyPlugin in governance.py owns the six ADK hook methods for the whole App:

  • before_model_callback: calls enforce_token_budget, compact_history, then redact_request_pii.
  • after_model_callback: calls record_token_usage, then redact_response_pii.
  • before_tool_callback: calls validate_actions.
  • after_tool_callback: calls secure_tool_output.
  • on_model_error_callback: calls handle_model_error.
  • on_tool_error_callback: calls handle_tool_error.
Deeper: why is the hook order load-bearing?

The plugin methods preserve first-non-None-wins chaining, and order is load-bearing: the budget check runs first because a refused turn should compact and redact nothing, compact_history then trims an over-long conversation window before the model sees it, and redact_request_pii masks only the surviving messages; on the way back, record_token_usage returns None so the redaction pass still sees every response.

Those functions live in budget.py, compaction.py, pii.py, and guardrails.py; the plugin composes them once. Per-agent callback lists made governance a clerical duty, so a newly added agent could silently omit redaction or budgeting. The App plugin now governs the root agent, every sub-agent, and every workflow node by construction. 2.1. First Agent shows the boundary, and 4.5. Guardrails owns the full pipeline.

Where do workflows fit?

An LLM agent chooses its own next step. That flexibility is a liability when the order is part of the requirement, so an ADK Workflow replaces model choice with explicit graph edges. The course ships a runnable, read-only investigation workflow:

# simplified
triage_workflow = Workflow(
    name="triage_workflow",
    description="Runs a bounded plan → investigate → evidence review → recommend loop.",
    edges=[("START", plan, investigate, evidence_review, recommend)],
)

Quoted verbatim from workflow.py. It always plans, investigates, reviews the evidence, then recommends, passing each result to the next node.

The default CLI, web UI, and A2A server still serve the flexible conversational agent. mise run workflow selects the deterministic alternative through the same validated src/agent package boundary.

The rule of thumb: deterministic control flow where order is a requirement, model choice only where contextual flexibility earns its cost. 3.5. Workflows owns the pattern.

Where does A2A fit?

A2A exposes an agent as a discoverable network service. ADK's to_a2a turns the same root_agent into an ASGI application — the standard Python interface a web server can run. That application carries an explicit agent card, runner, session service, and task store: the persistent server in server.py.

The agent is the unit of behavior; A2A is one way to make that unit callable by other services. 3.6. A2A introduces the protocol and what the card declares; Chapter 6 deploys this concrete server through kagent.

Where does each concept live in the repository?

Now that each concept has a name, here is the file that owns it. Each ADK abstraction has exactly one owning file, so when a boundary misbehaves you know which module to open. Read this table as the index to the rest of the chapter — abstraction on the left, the concrete code that implements it on the right:

ADK concept What it is Where it lives in this repo
Agent The unit of behavior composition.py root_agent (2.1. First Agent)
Model The provider client model.py build_model() (2.2. Models)
Instruction The persona and operating rules composition.py INSTRUCTION (2.3. Instructions)
Tools Capabilities the model may request tools.py, actions.py, memory.py, longterm.py, skills.py, mcp_client.py (Ch. 3)
Policy plugin Policy at the model/tool boundaries governance.py composes budget.py, compaction.py, pii.py, and guardrails.py (4.5. Guardrails)
Runner Drives one invocation server.py builds Runner(app=build_app(...), …); the deprecated agent= form is not used (2.4. Sessions)
Session/Task store Conversation and task persistence DatabaseSessionService, DatabaseTaskStore over .state/runtime.db (2.4. Sessions)
Events The run_async stream Consumed by the A2A executor (server.py) and the eval harness (evals/mlflow_eval.py)
Workflow A fixed, separately runnable graph workflow.py triage_workflow via mise run workflow (3.5. Workflows)

The same map as a picture: composition.py builds the agent, governance.py wraps it in a governed App, and server.py drives that app with persistent stores.

flowchart TD
    subgraph CR["Composition root · composition.py"]
      A["Agent<br/>root_agent"]
      I["Instruction<br/>INSTRUCTION"]
      MD["Model<br/>build_model()"]
      T["Tools<br/>tools · actions · memory · longterm · skills · mcp_client"]
      C["Policy inputs<br/>budget · compaction · pii · guardrails"]
    end
    P["AgentOpsPolicyPlugin<br/>governance.py"]
    APP["App<br/>build_app(root_agent)"]
    R["Runner<br/>server.py"]
    S["Session + Task store<br/>DatabaseSessionService · DatabaseTaskStore"]
    E["Events<br/>run_async stream"]
    A --- I & MD & T
    C --> P
    A --> APP
    P --> APP
    R -->|drives| APP
    R -->|reads/writes| S
    R -->|emits| E

Diagram in words: composition.py combines the instruction, model, and tools into root_agent. governance.py combines the policy functions into one plugin and attaches both to an App. server.py drives that app, persists sessions and tasks, and emits events.

What proves this page worked?

Open agents/python/src/agent/composition.py and identify the model, instruction, and tools. Then open governance.py and identify the single app-level plugin that composes the policy functions. Finally, open server.py and identify which resources are created once per application (runner, session_service, task_engine, task_store) and closed during lifespan shutdown.

You are done when:

  • You can point at the agent wiring in composition.py and the app-wide policy wiring in governance.py.
  • You can name the four resources server.py creates once per application and closes during lifespan shutdown.
  • You can restate the split this page opens with: what the model proposes, and what your code decides may actually run.
  • You can name at least two things ADK does not do for you.

If you cannot explain who owns sessions and tools — and what ADK does not enforce for you — pause before adding capabilities.

Continue to 2.1. First Agent when you can describe what the runner does with one message without scrolling back up this page.