Building agents
with Google
ADK 2.0

October 2026 – google-adk 2.13.0

Code owns the flow. Models own the judgment. Agents, tools and functions are all nodes in one graph.

0Python ADK 2.0 GA on 2026-05-19
0latest google-adk, on PyPI 2026-10-23
0GitHub stars on google/adk-python (2026-10-23)
0PyPI downloads in the last month (pypistats)
Go 2.0 GA · 2026-06-30 TypeScript 2.0 GA · 2026-08-21 Java and Kotlin: still 1.x
LLM island, max_llm_calls
User
START
triageClassifierNode
lookup_invoicefunction node
refund_policy_checkfunction node
approve?RequestInput
issue_refundfunction node
Agentmodel + tools
format_replyfunction node
END
LLM calls: 0
RUNNING WAITING COMPLETED node colours of the adk web graph view

The course climbs from first principles to production in nine chapters

Every chapter ends with an interview drill. Codelabs are cited with their exact titles.

1First principlesAgent = model + harness + loop. When is a workflow enough?
2ADK 2.0 building blocksTree to graph. Agent, App, Runner, sessions, state, plugins.
3Graph workflowsEdges, routes, fan-out/fan-in, loops, dynamic nodes, modes, YAML.
4Durable execution and HITLRequestInput, RetryConfig, checkpoint and resume.
5Context engineering, memory and skillsCompiled context, caching, compaction, Memory Bank, SkillToolset.
6Tools, MCP, A2A and multimodalACI, McpToolset, to_a2a, Parts, artifacts, Live API.
7Agentic design patternsRouter, parallel, orchestrator-workers, generator-verifier, deep agents.
8Loop, graph and harness engineeringPer-step verifiers, evals and guardrails.
9Production on Google CloudGemini Enterprise, Agent Runtime, Antigravity, agents-cli, AgentOps.
01
verification context tools loop gather · act · verify

First principles

An agent is a model inside a harness, not a prompt in a loop.

The loop is still a loop. What changed is everything around it.

  • What is an agent in 2026?
  • When is an agent worth its cost, compared with a workflow?
  • Which engineering disciplines replaced prompt tuning?

In 2026 the harness around the model decides most of the agent's quality

History, one line: in 2022 ReAct put Thought → Action → Observation in one growing prompt. Today that loop is only the outer skeleton.

Harness partADK 2.0 home
Model + loopLlmAgent with tools
ToolsFunctionTool, McpToolset
Context policyinclude_contents, compaction, caching
Session logsession events, checkpoint and resume
Sandboxhermetic code execution
Verifiersoutput_schema, function nodes, critics
HumanRequestInput, require_confirmation
TriggersPub/Sub and Eventarc triggers (2.9)

The loop is gather context → take action → verify work → repeat. A Workflow is the control flow of the harness. Plugins, schemas and evals are its sensors.

Every component in a harness encodes an assumption about what the model can't do on its own.

Session logappend-only events
Context policywhat this call sees
Sandboxcode runs here
TriggerPub/Sub · cron
ModelGemini
Toolsfunctions · MCP
Verifierschema · tests · critic
Humanapprove · edit
One growing prompt (ReAct, 2022) Thought → Action → Observation → Thought → Action → …

HumanEval, Andrew Ng, The Batch 2024-03-20

GPT-3.5 zero-shot
0
GPT-4 zero-shot
0
GPT-3.5 in an agent loop
0
0GPT-4, one prompt
0GPT-4, AlphaCodium flow (pass@5)

Climb from workflow to agent only when an eval shows the lower rung fails

Workflow: “systems where LLMs and tools are orchestrated through predefined code paths”

Agent: “systems where LLMs dynamically direct their own processes and tool usage”

one 20-step run · 95% reliable per step ◆ verify each step · assume a perfect check + 1 retry: 1 − 0.05² per step 100% end-to-end success step 1 step 20 · bar = P(all steps so far succeed)
0tokens per run, vanilla tool-calling agent
0tokens per run, ADK 2.0 workflow
0latency per run, was 7.2 s

Google: “If you can clearly map the workflow, use determinism.” The numbers are illustrative: refund task, gemini-3.5-flash, mock APIs.

Pitfall: a multi-agent design before one agent with tools has failed an eval.
tokens vs chat (Anthropic) workflows agents 1×4×15×
refund workflow · Why we built ADK 2.0 (verbatim)

workflow = Workflow(
    name="Refund_Workflow",
    edges=[
        (START, fetch_purchase_history, analyze_complaint_agent),
        (analyze_complaint_agent, route_complaint,
            {True: issue_refund, False: close_ticket}),
        (issue_refund, draft_email_agent, close_ticket),
    ]
)

Agent engineering moved outward: from the words of one call to the whole environment

“Loop engineering” and “graph engineering” are practice labels in this deck, not named publications.

Interview: flow engineering designs the sequence of model and verifier calls. Context engineering designs what each call sees.
DisciplineQuestion it answersWho named itADK 2.0 home
Flow engineeringWhat sequence of LLM and test steps solves this task class?Ridnik, Kredo, Friedman: AlphaCodium, arXiv 2401.08500, 2024-01-16Workflow edges, dynamic @node
Context engineeringWhat does the model see on this step?Popularized June 2025 by Lütke and Karpathy (via Simon Willison, 2025-06-27). Anthropic 2025-09-29: “the smallest possible set of high-signal tokens”include_contents, static_instruction, compaction, caching, artifacts, memory
Harness engineeringWhen the agent makes a mistake, what do I build so it never repeats it?Mitchell Hashimoto, 2026-02-05. Birgitta Böckeler, martinfowler.com, 2026-04-02: guides vs sensorsplugins, eval sets as regression tests, RetryConfig
Loop engineeringWhen does the loop stop, retry, reflect or escalate, and what does it cost?No single verified coiner. Roots: Huntley's “Ralph” loop, 2025-07-14max_llm_calls, RetryConfig, ReflectAndRetry*Plugin, ModelConsultTool, abort_signal
Graph engineeringWhich edges are hard-coded, and which does the LLM choose?12-Factor Agents, factor 8 “Own your control flow”. ADK 2.0 blog, 2026-07-01Workflow edges, Event(route=), ClassifierNode

Interview drill: defend when an agent is worth its cost

Workflow or agent for a refund process?
A workflow spine. The path is data-determined, it has irreversible side effects and it needs an audit trail. Use an LLM node only for the unstructured complaint. Google: “If you can clearly map the workflow, use determinism.” Its illustrative refund run went from 5,152 to 2,265 tokens.
A 20-step agent is 95% reliable per step. What is the end-to-end success, and what do you change?
About 36% (0.9520). Put cheap verifiers after the risky steps, hard-code the data-determined edges and remove steps. Each removed or verified step raises the product.
Why did the single growing ReAct prompt fail on long tasks?
Context rot: Chroma (2025-07-14) found that all 18 tested models degrade as input grows. Attention is an n² budget, and tool output piles up in the window. Modern harnesses move state into files, memory and subagents, and each call gets a compiled view.
What is harness engineering, and who named it?
Mitchell Hashimoto, 2026-02-05: each agent mistake triggers an engineered fix in the environment, so the mistake does not repeat. Böckeler splits the controls into guides (feedforward) and sensors (feedback), each computational or inferential. In ADK, each failure becomes an eval case and a CI gate.
02
AgentLLM node
Functiondef f(node_input)
Workflow
graph of nodesedges=[...]
BaseNodeone contract: run, yield events, return an output

ADK 2.0 building blocks

After this chapter you can build, run and inspect a first ADK 2.0 app. You can also explain what the App, the Runner, the session, the state and the plugins do.

README: “ADK applications are built using two main classes: Agent … and Workflow.”

Repo google/adk-python README

ADK 2.0 replaced the agent tree with a graph engine and kept the 1.x features as its base

The docs call 2.0 the move “from a hierarchical agent executor to a graph-based execution engine”. “Agents, Tools, and Functions are evaluated as individual nodes within a workflow graph.”

In Python, BaseAgent subclasses BaseNode.

  • 1.x: a root LlmAgent holds sub_agents. The model picks the next agent with transfer_to_agent.
  • 2.0: code owns the edges. START, function nodes and routers sit between the agents. A return value is the next node's input.
Python 2.13.0 Go v2.6.0 · adk/v2 TypeScript adk-v2.3.0 Java v1.13 · no 2.0 Kotlin v1.4 · no 2.0
Stay on 1.x: pip install "google-adk~=1.0" (1.39.1). 2.x needs Python 3.11+.
my_agent/agent.py · README quickstart

from google.adk import Agent, Workflow
generate_fruit_agent = Agent(
    name="generate_fruit_agent",
    instruction="Return the name of a random fruit. "
                "Return only the name.",
)
generate_benefit_agent = Agent(
    name="generate_benefit_agent",
    instruction="Tell me a health benefit about "
                "the specified fruit.",
)
root_agent = Workflow(
    name="root_agent",
    edges=[("START", generate_fruit_agent,
            generate_benefit_agent)],
)
terminal

$ pip install google-adk
$ adk run path/to/my_agent
$ adk web path/to/agents_dir

A first ADK agent is a model, an instruction and tools whose docstrings the model reads

Cloud Shell · scaffold and run

$ uv pip install --no-cache google-adk
$ adk create personal_assistant
? model, backend, project, region
$ cat personal_assistant/__init__.py
from . import agent
$ cat personal_assistant/.env
GOOGLE_GENAI_USE_ENTERPRISE=1
GOOGLE_CLOUD_PROJECT=<project>
GOOGLE_CLOUD_LOCATION=<region>
$ adk run personal_assistant
$ adk web
Dev UI: http://127.0.0.1:8000
sessions: <agents_dir>/<agent>/.adk/session.db
travel_assistant/agent.py · three tool kinds

from google.adk.agents import Agent
from google.adk.tools import FunctionTool, google_search
from google.adk.tools.agent_tool import AgentTool
from google.adk.tools.langchain_tool import LangchainTool
from langchain_community.tools import WikipediaQueryRun
from langchain_community.utilities import WikipediaAPIWrapper
google_search_agent = Agent(
    name="google_search_agent", tools=[google_search])
wiki = WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper(
    top_k_results=1, doc_content_chars_max=3000))
root_agent = Agent(
    name="root_agent",
    tools=[FunctionTool(get_fx_rate),     # plain def, calls an FX API
           AgentTool(agent=google_search_agent),
           LangchainTool(wiki)],
)

“The LLM uses the docstring to decide when and how to call the tool.”

User“EUR to JPY?”
root_agentAgent · model
FunctionToolget_fx_rate()
AgentToolsearch sub-agent
LangchainToolWikipediaQueryRun
Events tabshows the tool call
Read the model ID from an environment variable. Codelab IDs change: gemini-3.5-flash, gemini-3.8-flash, gemini-2.5-flash.

The App packages the agent; the Runner turns each yielded event into committed state

main.py · runs inside async def main()

from google.adk.apps import App
from google.adk.runners import InMemoryRunner
from google.genai import types

app = App(name="time_app", root_agent=root_agent)
runner = InMemoryRunner(app=app)
session = await runner.session_service.create_session(
    app_name="time_app", user_id="u1", state={"user:name": "Ada"})
msg = types.Content(role="user",
                    parts=[types.Part(text="Time in Paris?")])
async for event in runner.run_async(
        user_id="u1", session_id=session.id, new_message=msg):
    for call in event.get_function_calls():
        print("TOOL CALL", call.name, call.args)
    if event.is_final_response() and event.content:
        print(event.author, ":", event.content.parts[0].text)
A state change is “only guaranteed to be persisted” after the Runner processes the event that carries its state_delta.
Never edit session.state on a session fetched outside an invocation: it “will likely NOT be saved”.
PrefixScopePersisted
(none)this sessionDatabase / Vertex
user:all sessions of this user in this appDatabase / Vertex
app:all users and sessions of the appDatabase / Vertex
temp:this invocation, with its sub-agentsnever

Plugins enforce one policy on every agent, model and tool call; callbacks act on one agent

count_plugin.py · adk.dev/plugins

class CountInvocationPlugin(BasePlugin):
  def __init__(self) -> None:
    super().__init__(name="count_invocation")
    self.agent_count = 0
    self.llm_request_count = 0
  async def before_agent_callback(
      self, *, agent, callback_context) -> None:
    self.agent_count += 1
  async def before_model_callback(
      self, *, callback_context, llm_request) -> None:
    self.llm_request_count += 1   # Observe: return None

app = App(name="test_app_with_plugin",
          root_agent=root_agent,
          plugins=[CountInvocationPlugin()])
runner = InMemoryRunner(app=app)

Built-ins: ReflectAndRetryToolPlugin, ReflectAndRetryModelPlugin (2.6), ContextFilterPlugin, GlobalInstructionPlugin, BigQueryAgentAnalyticsPlugin, SaveFilesAsArtifactsPlugin, MultimodalToolResultsPlugin, AutoTracingPlugin (2.2), Model Armor (2.8), ToolCallIntegrityPlugin (2.11), ForceEscalationPlugin (2.13). 2.0 ignores _run_async_impl() overrides: use hooks.

Agent callbacksPlugins
Scopeone agent objectevery agent, model and tool
Orderafter the pluginsfirst; non-None skips the rest
ConfigYAML *_callbacks keysApp(plugins=[…]), --extra_plugins
Use forper-agent logic, state seedingsecurity guardrails and policies

Interview drill: explain the ADK 2.0 runtime without hand-waving

What is the difference between a plugin and a callback? What does a non-None return from before_model_callback do?
A callback acts on one agent. A plugin is a named BasePlugin class on the App. It runs first, for every agent, model and tool call. A returned LlmResponse skips the LLM call, the later plugins and the agent callbacks. Use this for caches and guardrails.
Which state prefix do you use for a user's cart and for scratch data? When is a write persisted?
Use user:cart: all sessions of that user in the app share it. Use temp:x for scratch: ADK never persists it. A write is committed only after the Runner processes the yielded event that carries its state_delta. Before that, local code can see a dirty read.
What breaks when you upgrade from ADK 1.x to 2.0?
Events get node_info and output. Rigid DB tables and strict validators fail, so upgrade all readers first and use adk migrate session. Overrides of _run_async_impl are ignored. session.events.append breaks determinism: yield the event. A broad except stops retries, and catching BaseException traps NodeInterruptedError.
Where do you register plugins in ADK 2.x Python? How do you add one to a YAML-only agent?
Put them on App(plugins=[...]), then build Runner(app=app). Runner(plugins=) is deprecated, and both together raise ValueError. AgentConfig has no plugins key, so pass adk web --extra_plugins=…. These plugins run after the App's own plugins.
03
START
fetch_conditionsfunction node
route_by_weatherEvent(route=…)
hot_strategyAgent
cold_strategyAgent

Graph workflows

“Workflows separate execution routing from language processing.”

graph-based · Workflow(edges=[...]) dynamic · @node + ctx.run_node collaborative · coordinator + sub-agent modes

A chain tuple wires nodes, and each return value becomes the next node's input

agent.py · adk.dev/graphs “Get started”

from google.adk import Agent, Event, Workflow
from pydantic import BaseModel

city_generator_agent = Agent(name="city_generator_agent",
    model="gemini-flash-latest",
    instruction="Return the name of a random city. "
                "Return only the name, nothing else.")

class CityTime(BaseModel):
    time_info: str  # time information
    city: str       # city name
def lookup_time_function(node_input: str):
    return CityTime(time_info="10:10 AM", city=node_input)

city_report_agent = Agent(name="city_report_agent",
    model="gemini-flash-latest", input_schema=CityTime,
    instruction="It is {CityTime.time_info} in {CityTime.city} right now.")
def completed_message_function(node_input: str):
    return Event(message=f"{node_input}\n WORKFLOW COMPLETED.")

root_agent = Workflow(name="root_agent", edges=[
    ("START", city_generator_agent, lookup_time_function,
     city_report_agent, completed_message_function)])

Every node runs the same three phases

  1. Validate node_input against input_schema. Dicts are coerced into the model.
  2. Run the user logic: a function, an Agent, a BaseTool or a nested Workflow.
  3. Normalize each yield: None is dropped, an Event passes, a RequestInput becomes an interrupt, any other value becomes Event(output=value).

Four Event fields carry the run

outputData for the next node. Only one per node run.
messageContent that the user sees.
stateA delta that the session saves.
routeThe branch to take (next slide).

The hand-off is the return value. You do not need session state for it.

Workflow(...) checks 10 rules at construction

  • Unique node names. Reuse one instance to loop back.
  • START is present, with no incoming edges and no routes.
  • Every node is reachable. No unconditional cycles.
  • output_schema equals the successor's input_schema (often from type hints).
  • An LlmAgent(mode='chat') only directly after START.

The check is structural, not behavioural.

from google.adk import START fails. Import START from google.adk.workflow. The string "START" works only in chain tuples.
Two terminal nodes that both output raise WorkflowConfigurationError. Put a JoinNode before the end.
A node is any NodeLike: a function, an Agent, a tool or a Workflow.

A router emits a route label, and only the matching edge fires

A node yields Event(route=v). The value v is a str, int or bool. A routing dict maps v to the next node.

The router emits "BUG". Only the edge with the key "BUG" fires. The other branches do not run.

A list route fans out: Event(route=["BUG", "LOGISTICS"]) starts every matching branch.

No match and no default: the branch ends silently. ADK writes only a log warning. Other branches continue.

DEFAULT_ROUTE (the literal "__DEFAULT__") fires when a route is emitted and no key matches. Since 2.10 it can fan out.

  • (a) Function router: rules in code (codelab L2b).
  • (b) LLM + schema: a Literal field, then a function routes.
  • (c) ClassifierNode (2.13, labs): a score and a threshold.
routing_workflow · adk.dev/graphs

from google.adk import Event, Workflow
from google.adk.workflow import DEFAULT_ROUTE

def router(node_input: str):
    routes = [r.strip() for r in node_input.split(",")]
    return Event(route=routes)      # a list fans out

root_agent = Workflow(name="routing_workflow", edges=[
    ("START", process_message, router),
    (router, {"BUG": response_1_bug,
              "CUSTOMER_SUPPORT": response_2_support,
              "LOGISTICS": response_3_logistics,
              DEFAULT_ROUTE: fallback}),   # catch-all
])
Pitfall: an LLM router with free-text output can invent a label, for example "REFUND". With no default edge, the request stops with no error. Constrain the label with a Literal schema and add DEFAULT_ROUTE.
samples/workflows/classifier_node · new in 2.13 (labs)

from google.adk.labs.classifiers import (
    Binary, Choice, ClassifierNode, LlmClassifier)

classifier = LlmClassifier(model="gemini-3.5-flash")
triage = ClassifierNode(name="triage", classifier=classifier,
    question=Choice(
        instructions="Which team should handle this support ticket?",
        criteria={"billing": "Invoices, refunds, or subscription charges",
                  "technical": "Bugs, errors, outages, or API problems",
                  "general": "Product questions and anything else"}),
    answer_key="triage_answer")
is_urgent = ClassifierNode(name="is_urgent", classifier=classifier,
    question=Binary(instructions="Is this an outage, or does it "
                    "block the customer's production use?"),
    threshold=0.7, answer_key="urgency_answer")

edges = [("START", process_input, triage),
         (triage, {"billing": billing_agent,
                   "technical": is_urgent, "general": faq_agent}),
         (is_urgent, {True: page_oncall, False: eng_queue})]

TypesafeClassifier returns “calibrated probabilities in a single forward pass, much faster than an LLM” (CHANGELOG 2.13.0).

Fan-out runs branches in parallel, and a JoinNode waits for every one of them

  • A tuple of nodes inside a chain is a parallel fan-out.
  • JoinNode sets _requires_all_predecessors → True. It emits one dict keyed by predecessor node name.
  • Compact form: (START, (word_count, shout), join, report). Then report reads node_input['shout'] and node_input['word_count'].
  • Without a join, the successor runs once per predecessor (sample multi_triggers).
  • Map over a runtime list: @node(parallel_worker=True) or Agent(..., parallel_worker=True). One run per item. The next node gets a list.
  • max_parallel_workers caps the per-item concurrency. Here it is 2: two run, two wait.
  • Codelab level L4a sizes the fan-out at runtime. Level L2a uses a fixed tuple and a JoinNode.

Workflow(max_concurrency=2) caps graph-scheduled nodes only. ctx.run_node children are exempt: “throttling them would cause deadlock”.

0–1model calls per level, codelab L0–L2b
5–30model calls per run, L4b fan-out

Fixes: 2.8 counts START as a satisfied join predecessor. 2.13 makes a JoinNode in a loop wait for each iteration's predecessors.

samples/workflows/fan_out_fan_in + parallel_worker

from google.adk import Event, Workflow
from google.adk.workflow import JoinNode, node
def make_uppercase(node_input: str): return node_input.upper()
def count_characters(node_input: str): return len(node_input)
def reverse_string(node_input: str): return node_input[::-1]
join_node = JoinNode(name="join_for_results")
async def aggregate(node_input: dict):
    yield Event(message=f"Upper: {node_input['make_uppercase']} | "
                        f"Len: {node_input['count_characters']} | "
                        f"Rev: {node_input['reverse_string']}")

root_agent = Workflow(name="root_agent", edges=[(
    "START", (make_uppercase, count_characters, reverse_string),
    join_node, aggregate)])

@node(parallel_worker=True)          # map: one run per list item
def make_upper_case(node_input: str):
    yield node_input.upper()
Pitfall: two parallel branches that write the same state key race. Give each branch its own key, or pass data through node outputs into the join. Interview angle: parallelism multiplies token cost, so justify it with latency or with independent sources.

A loop is a conditional back-edge, and you must bound it yourself

Sample workflows/loop (generator-critic): (route_headline, {"unrelated": generate_headline}) is a conditional back-edge.

evaluate_headline has output_schema=Feedback with grade: Literal["tech-related", "unrelated"]. The router returns Event(route=node_input.grade).

A self-loop also works: (guess_number, {"guessed_wrong": guess_number}).

“A graph cycle is not bounded automatically.”

Construction rejects only unconditional cycles. A conditional cycle has no max_iterations. Each pass re-activates the node “with a fresh lifecycle”, and node_info.path shows the pass, e.g. request_input@1/request_human_review@2.

Lever 1: a round counter in the router

The router reads rounds from ctx.state, adds one, and writes it back through Event(state=...). The counter survives a pause and a resume, because the session stores it.

Lever 2: escalate on the last round

On round 3 the router emits give_up. The edge goes to human_review, which yields a RequestInput.

Backstop: RunConfig.max_llm_calls (default 500) raises LlmCallsLimitExceededError. Since 2.8 you can set ADK_MAX_LLM_CALLS.

1.x loop vs 2.0 loop

The Next '26 codelab uses LoopAgent(sub_agents=[...], max_iterations=3) with a researcher, a judge and an escalation checker. The checker yields EventActions(escalate=True) on "pass".

In 2.0 the judge feeds a router, and the back-edge is the loop. Since 2.5, ADK rejects old orchestrators inside Workflow graphs.

bounded_critic.py · pattern composed from verified APIs

from google.adk import Context, Event, Workflow

MAX_ROUNDS = 3

def route_headline(node_input: Feedback, ctx: Context):
    rounds = ctx.state.get("rounds", 0) + 1
    if node_input.grade == "tech-related":
        return Event(route="pass", state={"rounds": rounds})
    if rounds >= MAX_ROUNDS:
        return Event(route="give_up", state={"rounds": rounds})
    return Event(route="revise", state={"rounds": rounds})

root_agent = Workflow(name="bounded_critic", edges=[
    ("START", process_input, generate_headline,
     evaluate_headline, route_headline),
    (route_headline, {"revise": generate_headline,
                      "pass": publish,
                      "give_up": human_review}),
])

Dynamic workflows use plain Python control flow and still checkpoint every child node

code_workflow · bounded verifier loop (verified APIs, sketch binding)

from google.adk import Context
from google.adk.workflow import node

@node(rerun_on_resume=True)      # required for ctx.run_node
async def code_workflow(ctx: Context, node_input: str):
    code = await ctx.run_node(coder_agent, node_input=node_input)
    for _ in range(3):           # bounded loop
        check = await ctx.run_node(lint_and_test, node_input=code)
        if not check.findings:
            return code
        code = await ctx.run_node(fixer_agent,
            node_input={"code": code, "findings": check.findings})
    raise RuntimeError("verification failed after 3 rounds")
  • Loops, recursion and asyncio.gather are ordinary Python. A parent that calls ctx.run_node must set rerun_on_resume=True.
  • Graph: declarative, checked by 10 rules, drawn in adk web. Dynamic: data-dependent loops and recursion.
  • Codelab L4b recurses only while depth < MAX_DEPTH.
  • Each child run gets a deterministic id: the parent id plus a counter "1", "2", … These ids are the checkpoint keys.
  • A custom run_id must contain a non-numeric character. The docs discourage custom ids.
  • Always await ctx.run_node directly. In asyncio.create_task() “errors are silently swallowed”.

“Successful sub-nodes are automatically skipped when resuming the workflow, making complex logic durable and resumable by default.”

Since 2.9 a failed node runs again on resume. Make node side effects idempotent.

parallel children · adk.dev/graphs/dynamic

tasks = [ctx.run_node(real_node, item) for item in node_input]
return await asyncio.gather(*tasks)   # resume: failed ones only
Pitfall: the adk.dev Python “Loop route” sample has no round cap. Always bound the loop.

In collaborative mode a coordinator delegates through tools that ADK generates for each sub-agent

User
travel_plannercoordinator · no mode
chat sub-agentmode='chat' (default)
flight_bookermode='task' · may ask user
weather_checkermode='single_turn'

ADK injects one delegation tool per sub-agent, named after it. Do not set mode on the root agent.

chat (default, the 1.x behaviour): the sub-agent takes the user thread. It returns only by a manual transfer_to_agent. This is a handoff (OpenAI Agents SDK: handoffs=).

task: the sub-agent may ask the user a clarifying question, then returns automatically through finish_task. A task agent must be a leaf. Codelab level L3b shows the pause and the resume.

single_turn: no user turns, automatic return, can run in parallel. Each parallel sub-agent runs in its own session branch and cannot see its peers' events. This is agent-as-tool (Agent.as_tool(), ADK AgentTool).

Version trap: Python 2.0.0 disables task mode in graph workflows. Versions 2.5 and 2.12 add task-mode resume. Test on your version.
travel_planner · adk.dev/workflows/collaboration

from google.adk import Agent

weather_agent = Agent(
    name="weather_checker", mode="single_turn",
    tools=[get_weather, user_info, geocode_address])
flight_agent = Agent(
    name="flight_booker", mode="task",
    input_schema=FlightInput, output_schema=FlightResult,
    tools=[search_flights, book_flight])
root = Agent(name="travel_planner",
             sub_agents=[weather_agent, flight_agent])
# Auto-injects delegation tools named after each subagent
chat (default)tasksingle_turn
User turnsfullclarification onlynone
Returnmanual transfer_to_agentautomatic finish_taskautomatic, with result
Parallelnonoyes

YAML configs declare agents and whole graphs, while Python keeps the logic

samples/workflows/loop_config/root_agent.yaml · 2.9.0+

agent_class: Workflow
name: root_agent
edges:
  - - START
    - .agent.process_input
    - generate_headline.yaml
    - evaluate_headline.yaml
    - .agent.route_headline
  - - .agent.route_headline
    - unrelated: generate_headline.yaml
  • A block list is a chain. An inline list [a, b] is a fan-out.
  • A mapping is a routing map, keyed by Event(route=...).
  • .agent.fn is a Python function. x.yaml is an agent file.
  • Nodes are cached by name, so the second generate_headline.yaml makes a real cycle.

$ adk create --type=config my_agent   # root_agent.yaml + .env
$ adk web .

Keys include agent_class, model, instruction, tools, sub_agents, output_schema and output_key.

Docs: “experimental and has some known limitations”. Only Gemini models.

  • ADK parses with yaml.safe_load only.
  • Every *_code and *_callbacks reference is checked against a denylist: the entire stdlib, plus yaml and ruamel.
  • Absolute paths are rejected. The args denylist is off by default.
  • “Treat a config that can name arbitrary modules as trusted input.”
If agent.py or __init__.py exposes root_agent, the Python wins and ADK ignores your YAML edits.
AgentConfig.json is deprecated and has no Workflow. Ignore its editor warnings on graph YAML.
agent.py · Python keeps the logic

class Feedback(BaseModel):
  grade: Literal["tech-related", "unrelated"] = Field(description="...")
  feedback: str = Field(description="...")

def process_input(node_input: str):
  return Event(state={"topic": node_input})
def route_headline(node_input: Feedback):
  return Event(route=node_input.grade)
evaluate_headline.yaml · the per-step verifier

agent_class: LlmAgent
name: evaluate_headline
instruction: |
  Grade whether the headline is related to technology or software engineering.
output_schema:
  name: loop_config.agent.Feedback
output_key: feedback

The trust gate for code references

Interview drill: design and debug an ADK 2.0 graph

How do you branch, and what happens to a route with no matching edge?
Yield Event(route=...) and map routes in a dict. An unmatched route ends the branch silently, with only a log warning. Add DEFAULT_ROUTE ("__DEFAULT__"). Constrain LLM routers with a Literal output schema, or use a ClassifierNode with a threshold.
Why can a graph cycle run forever, and how do you bound it?
Construction rejects only unconditional cycles. A conditional cycle has no max_iterations. Keep a round counter in ctx.state with a give_up route, or use a dynamic @node with a for bound. Backstop: max_llm_calls (default 500).
Graph workflow or dynamic workflow?
Graph for declarative flows: 10 construction rules check it, and adk web draws it. Dynamic (ctx.run_node, while, gather) for recursion and data-dependent loops. The parent needs rerun_on_resume=True. Deterministic child run ids are the checkpoint keys.
Two terminal nodes both produce output. What happens?
The run fails with WorkflowConfigurationError (“multiple terminal nodes produced output”), because the engine cannot choose the workflow output. Join them with a JoinNode. It emits one dict keyed by predecessor node name.
04
START
fetch_orderfunction node
draft_replyAgent node
approve?RequestInput
charge_cardside effect
pause: WAITING
crash: replay
retry: backoff

Durable execution and human-in-the-loop

Long runs must pause for people, survive crashes and retry safely.

“any node can pause the graph and ask a human a question”

RequestInput resume by replay RetryConfig

RequestInput pauses the graph as a long-running function call that any client can answer

rerun_on_resume

False (functions): the reply becomes the node's output and goes to the successor.

True (LlmAgent, Workflow): the node runs again and reads ctx.resume_inputs.

Approval gates

  • @node(require_confirmation=…) 2.12
  • tool nodes pause via RequestInput 2.11
  • FunctionTool(require_confirmation=…)
  • tool_context.request_confirmation()

Recipe ambient-expense-agent: under $100 goes to auto_approve with no LLM call. $100 or more goes to a single_turn review agent, then to RequestInput.

samples/workflows/request_input/agent.py

from google.adk.events import RequestInput
def step1():  # the reply goes to the next node
  yield RequestInput(message="Enter a number:")

def handle_human_review(node_input: str):
  if node_input == "reject":
    yield Event(route="rejected")
  elif node_input == "approve":
    yield Event(route="approved")
  else:
    yield Event(state={"feedback": node_input}, route="revise")

root_agent = Workflow(name="request_input", edges=[
  ("START", process_input, draft_email,
   request_human_review, handle_human_review),
  (handle_human_review, {"revise": draft_email,
    "approved": send_email, "rejected": reject_email})])
response_schema does not reformat a free-text reply. Put an agent after the pause to normalize it.

RetryConfig handles transient faults, and a broad except silently disables it

RetryConfig

  • Transient faults. Fires on an exception. A verifier can raise to get a retry.
  • The failed node reruns with its ctx.run_node children. Completed children replay.
  • A failed sub-workflow retries as a whole. parallel_worker retries per item.
  • The model sees nothing.
vs

ReflectAndRetryToolPlugin

  • Semantic faults. The error goes back to the model as reflection guidance.
  • max_retries=3 gives 4 attempts in total.
  • Override extract_error_from_result to catch {"status": "error"}.
  • Register it once on App(plugins=[...]).
samples/workflows/retry/agent.py

from google.adk.workflow import RetryConfig, node
@node(retry_config=RetryConfig(max_attempts=5, initial_delay=1))
def get_weather(ctx: Context) -> str:
  yield Event(message=f"attempt {ctx.attempt_count}")
  if random.random() < 0.7:
    raise HTTPError(url="http://mock-api.example.com",
                    code=500, msg="ISE", hdrs={}, fp=None)
  yield "sunny"
pitfall.py

try:
    data = call_api()
except Exception:          # masks the failure: retries never fire
    return {"status": "error"}
except BaseException: ...  # traps NodeInterruptedError: breaks HITL
Other levers. @node(timeout=30.0) raises NodeTimeoutError, which is retryable. abort_signal stops a run (2.11). FallbackModel fails over to another model (2.9).

Resume replays the event log: finished nodes fast-forward and failed nodes run again

Always on, in one invocation

The Workflow scans the session events (ReplayManager.scan_workflow_events) and rebuilds completed nodes, outputs and pending interrupts. Completed nodes fast-forward. The interrupted node runs again.

Limit: checkpoints are written but not read yet. Resume cost grows with history length.

is_resumable=True

  • Adds durability: resume across separate runner calls, with no new message.
  • agent_state / end_of_agent checkpoint events in the log.
  • Function responses go to their original invocation.
  • The ADK Web UI and CLI cannot resume.
adk.dev/runtime/resume

app = App(name="my_resumable_agent", root_agent=root_agent,
    resumability_config=ResumabilityConfig(is_resumable=True))
# resume: the same user, session and invocation id
async for event in runner.run_async(user_id="u_123",
        session_id="s_abc", invocation_id="invocation-123"):
    print(event)
or over HTTP

$ adk api_server my_resumable_agent/
$ curl -X POST http://localhost:8000/run_sse \
    -H "Content-Type: application/json" \
    -d '{"app_name": "my_resumable_agent", "user_id": "u_123",
         "session_id": "s_abc",
         "invocation_id": "invocation-123"}'
Upgrade every reader of a shared session DB before you write 2.0 events. The new node_info and output fields break rigid schemas. Use adk migrate session.

Peers: Temporal activities · Restate ctx.run() · DBOS steps · LangGraph checkpointers by thread_id · Anthropic Managed Agents wake(sessionId).

Chapter 04 · Durable execution and HITL Interview lens at-least-once

Interview drill: make a paused, crashed or retried run behave correctly

How does human-in-the-loop work on the wire in ADK 2.0?
RequestInput becomes a long-running functionCall. The event lists its interruptId in longRunningToolIds. The client resumes with functionResponse(name="adk_request_input", id=...). Any client that can send a FunctionResponse can resume the run.
rerun_on_resume: true or false?
False: the node completes, and the human reply is its output. True: the node runs again from the top and reads ctx.resume_inputs. A parent that calls ctx.run_node needs True. require_confirmation turns True on for you.
After a crash, the resumed workflow charged the card twice. Why, and what is the fix?
Execution is at-least-once. Since 2.9.0, a failed node runs again on resume. Give each side effect an idempotency key (for example the order id), so the API refuses a repeat. Put a require_confirmation gate on high-risk nodes, and do not change the graph while runs are paused.
Why did your retries never fire?
A broad except Exception swallowed the error, so RetryConfig never saw a failure. Let exceptions propagate, or re-raise. Never catch BaseException: it traps NodeInterruptedError and breaks HITL pauses.
05

Context engineering, memory and skills

The goal is "the smallest possible set of high-signal tokens that maximize the likelihood of some desired outcome."

Each model call sees a small compiled view of a much larger session

StrategyADK primitives
Writeoutput_key · Event(state=…) · ArtifactService · MemoryService
Select{key} / {key?} · <Class.field from node> · preload_memory / load_memory · LoadArtifactsTool · Jinja2 (2.7)
CompressEventsCompactionConfig · ContextFilterPlugin
Isolateinclude_contents='none' single_turn isolation_scope node_input

Why compile: context rot

Chroma tested 18 LLMs. Accuracy drops as the input grows, even on simple tasks. n tokens give n² attention pairs (Anthropic).

Poisoning

A hallucination enters the context and gets re-cited.

Distraction

Long history outweighs what the model learned in training.

Confusion

Irrelevant material, such as too many tools, steers the output.

Clash

Parts of the context contradict each other.

Google ADK: "the Session is your ground truth; the working context is merely a computed projection."

critic.py · adapted from adk.dev loop agents

critic = LlmAgent(
    name="Critic",
    model=MODEL,
    include_contents="none",
    instruction=(
        "Review {current_document}. If fine reply "
        "exactly 'No major issues found.'"),
    output_key="criticism",
)

{key} fails on a missing key; {key?} is optional. Since 2.10, ${var} stays literal text.

In a graph, each node gets only its node_input. Tool outputs do not pile up in one context, so the architecture does the isolation.

Cache the stable prefix and compact the tail

app.py · adk.dev/context/caching + /compaction

from google.adk.apps.app import App, EventsCompactionConfig
from google.adk.agents.context_cache_config import ContextCacheConfig
app = App(name="support_app", root_agent=root_agent,
    context_cache_config=ContextCacheConfig(          # Python 1.15+
        min_tokens=2048, ttl_seconds=600, cache_intervals=5),
    events_compaction_config=EventsCompactionConfig(  # Python 1.16+
        token_threshold=4000,    # compact when exceeded
        event_retention_size=5,  # keep the last 5 raw events
        # or sliding window: compaction_interval=3, overlap_size=1
    ),
)

Cache defaults: min_tokens=0, ttl_seconds=1800, cache_intervals=10. ADK 2.8 added Anthropic cache breakpoints. ContextFilterPlugin(num_invocations_to_keep=N) trims history before each model call.

  • Manus: the KV-cache hit rate is "the single most important metric" for a production agent. Input:output is about 100:1.
  • Keep the prefix stable and the context append-only.
  • Serialize deterministically. Mask tools; do not remove them.
  • Offload: Deep Agents move tool output over 20,000 tokens to a file and keep a path plus a 10-line preview.
A timestamp in the system instruction makes every call a cache miss. Each compaction also rewrites the prefix once, so compact rarely and in large chunks.

Sessions hold one conversation, and Memory Bank keeps consolidated facts across sessions

ADK MemoryService backends

InMemoryMemoryService: keyword match
VertexAiMemoryBankService: managed
VertexAiRagMemoryService: RAG corpus
SqliteMemoryService (2.11)
MongoDbMemoryService (2.13)
delete_memory() (2.13)

Agent Platform Memory Bank

Revisions with rollback · TTLs
Memory Profiles GA 2026-07-15
Gemini 3.5 Flash default (2026-06-29)
RMM (ACL 2025): >10% on LongMemEval
Payhawk: submission time cut >50%
ReasoningBank (ICLR 2026): WebArena +8.3

Memory poisoning: an injected "fact" persists across sessions. Always scope memories by user_id, set TTLs, and purge.
agent.py · adk.dev/sessions/memory

from google.adk.tools import preload_memory

async def auto_save(callback_context):
    await callback_context.add_session_to_memory()

agent = Agent(
    model=MODEL, name="Generic_QA_Agent",
    instruction="Answer the user's questions",
    tools=[preload_memory],          # search every turn
    # or tools=[load_memory]: the model decides when
    after_agent_callback=auto_save,  # you write memory
)

$ adk web path/to/agents_dir \
    --memory_service_uri="agentengine://1234567890"

Agent Skills load know-how in three levels, so an unused skill costs almost no tokens

copywriter agent · Creative Studio codelab (excerpt)

from google.adk.skills import load_skill_from_dir
from google.adk.tools import skill_toolset

_instagram_skill = load_skill_from_dir(
    pathlib.Path(__file__).parent / "skills" / "instagram-copywriting")
_copywriting_skills = skill_toolset.SkillToolset(skills=[_instagram_skill])
skills/pdf-forms/SKILL.md
---
name: pdf-forms
description: Fill and extract PDF form fields.
  Use when the user mentions PDF forms.
---
# PDF forms
1. Run `python scripts/extract_fields.py <file>`
SkillToolset · 1.25 Skill Registry · 1.34 ADK_ENABLE_SKILL_LIFECYCLE=1 · 2.10 script output → artifact 2.12 GEPARootAgentOptimizer
A vague description misfires. Too many skills bloat level 1. An untrusted skill bundles scripts: a supply-chain and injection vector.

Interview drill: control what the model sees and remembers

Name four context failure modes. How does a graph limit poisoning?
Poisoning, distraction, confusion and clash (Breunig). In a Workflow graph each node sees only its node_input, and include_contents='none' gives a sub-agent no caller history. So a bad fact stays inside one node and does not spread to every later call.
When do you use preload_memory, and when load_memory?
preload_memory searches on every turn: predictable, but it costs tokens and latency each turn. load_memory is a tool, so the model searches only when it needs past context. Neither one writes memory: call add_session_to_memory, usually in after_agent_callback.
Why can the current time in the system instruction raise cost about 10x?
It changes the prefix on every call, so the KV/context cache never hits. Manus pays $0.30 per MTok for cached input and $3 for uncached input. Put stable text in static_instruction and dynamic data in the tail of the context.
Skill, rule, tool or sub-agent: what is the difference?
A rule is always-on guidance. A skill is an on-demand folder of know-how, loaded by progressive disclosure. A tool is one callable function. A sub-agent is a separate context with its own prompt, tools and model, and it returns a summary.
18 LLMs show context rot input:output ≈ 100:1 token_threshold=4000 · event_retention_size=5 min_tokens=2048 · ttl_seconds=600 SkillToolset since 1.25
06
LlmAgentroot_agent
Function tooldef get_weather()
MCP serverMcpToolset
Remote agentRemoteA2aAgent
Mic + camerarun_live

Tools, MCP, A2A and multimodal

Tools are the agent's hands. Protocols and modalities connect it to more systems.

"MCP standardizes how an agent connects to tools… A2A standardizes how independent agents discover each other, delegate tasks, and share results."
FunctionTool McpToolset to_a2a types.Part run_live

Design tools as an interface for the model: few, built for workflows and hard to misuse

list_contacts()0% tokens
{"id":"8f3a9c2e-41d7-4b0e-9a6f-02c1","org":"5b1e…
{"id":"d27c0a11-9e4f-4c3b-8d5a-77e0","org":"5b1e…
{"id":"0b9e6f3d-2a18-4f7c-b1d4-c93a","org":"a04f…
{"id":"e61f8b20-73cd-41a9-90e2-5d6b","org":"5b1e…
{"id":"4a7d2c9b-0f61-4e8a-a3c5-18f9","org":"c7d2…
{"id":"91c3e5a7-6b2d-4d0f-8e14-a2b8","org":"5b1e…
search_contacts(name="Silva",
response_format="concise")
Ana Silva · Sales · Lisbon
Rui Silva · Legal · Porto
Eva Silva · Ops · Madrid
0% tokens
Traceback (most recent call last): … KeyError: 'Ana K'
{"status": "error"} No contact named Ana K. Try search_contacts with a partial name.
  • Namespace by service and resource: asana_projects_search.
  • Paginate and truncate. Claude Code caps one tool result at 25,000 tokens.
  • Make wrong arguments hard to send (poka-yoke): ask for absolute paths. Keep the tool set small and without overlap.
  • Take the user identity from trusted state, never from a model argument (validate_tool_params).
Pitfall: the tool raises on a business error. Return {"status": "error", ...} instead. The model reads the dict and can try again.
0task time cut after an agent rewrote a tool description
0tool tokens with Tool Search + defer_loading: 77K → 8.7K
0tokens: code execution with MCP, 150K → 2K, needs a sandbox
tools.py · ADK 2.x

from google.adk.tools import FunctionTool, ToolContext

def add_to_cart(item: str, tool_context: ToolContext) -> dict:
    """Adds an item to the user's cart."""
    cart = tool_context.state.get("user:cart", [])
    cart.append(item)
    tool_context.state["user:cart"] = cart      # all sessions
    tool_context.state["temp:last_op"] = "add"  # this call only
    return {"status": "success", "cart_size": len(cart)}

async def confirmation_threshold(amount: int,
                                 tool_context: ToolContext) -> bool:
    return amount > 1000
tools = [FunctionTool(reimburse, require_confirmation=True),
         FunctionTool(reimburse_big,
                      require_confirmation=confirmation_threshold)]

ADK tool families: plain functions (auto-wrapped as FunctionTool) · AgentTool · LongRunningFunctionTool · built-ins google_search, url_context, code execution · OpenAPIToolset · BigQueryToolset

MCP connects an agent to tools, and A2A connects it to other agents

MCP · agent → toolsA2A · agent → agent
PeerTransparent, schema-typed functionsOpaque agent: own model, memory, policy
Discoverylist_tools · server/discover (2026-07-28)Agent Card: /.well-known/agent-card.json
Unit · wireTool call · stdio or Streamable HTTPTask, Message, Part, Artifact · JSON-RPC, gRPC, HTTP+JSON
ADKMcpToolset · to_mcp_server (2.5)RemoteA2aAgent · to_a2a · Experimental
Spec2026-07-28: stateless, MRTR. AAIF (Linux Foundation) since 2025-12-09v1.0.0 (2026-03-12): JWS-signed cards. 150+ orgs
Codelab: currency_agent + travel_agent

from google.adk.tools.mcp_tool import (
    McpToolset, StreamableHTTPConnectionParams)
root_agent = LlmAgent(
    model=MODEL, name="currency_agent",
    tools=[McpToolset(connection_params=
        StreamableHTTPConnectionParams(url=MCP_SERVER_URL))])

from google.adk.a2a.utils.agent_to_a2a import to_a2a
a2a_app = to_a2a(root_agent, host=host, port=port,
                 protocol=protocol)   # ASGI app + Agent Card

from google.adk.agents.remote_a2a_agent import (
    RemoteA2aAgent, AGENT_CARD_WELL_KNOWN_PATH)
currency_remote_agent = RemoteA2aAgent(name="currency_agent",
    agent_card=f"{CURRENCY_AGENT_URL}{AGENT_CARD_WELL_KNOWN_PATH}")
Pitfall: no tool_filter. Then the model sees every tool on the server. The docs say: "Always supply tool_filter=[...]". MCP Toolbox (v1.15.0, 40+ sources) keeps SQL in tools.yaml, so the model picks a named query.

Every modality is a Part: keep the bytes in artifacts and pass references

types.Part holds one payload: text · inline_data (bytes + mime_type) · file_data (Files API URI, gs://, HTTPS, YouTube) · function_call/response (images on Gemini 3+) · thought + signature.
Inline under ~20 MB is always safe. Use the Files API above 100 MB (PDF 50 MB): 2 GB per file, 20 GB per project, 48 h, free. On Agent Platform, use gs:// URIs.

media_resolution
Q1 Q2 Q3 Q4
Image1,120
PDF page560
Video frame70
tokens/item · images high · PDFs medium (saturates) · video low/medium

Model churn: Imagen 4 and Veo 3.1 are shut down on the Gemini API (Veo on 2026-10-22). Images: gemini-nano-banana-2.1. Video: gemini-omni-1.1-flash. Read model IDs from env vars.

contributing/samples/multimodal/generate_image (trimmed)

from google.adk import Agent
from google.adk.tools import ToolContext, load_artifacts
from google.genai import Client, types

async def generate_image(prompt: str, tool_context: ToolContext):
  """Generates an image based on the prompt."""
  response = Client().models.generate_images(
      model="imagen-3.0-generate-002",  # retired model ID
      prompt=prompt, config={"number_of_images": 1})
  image_bytes = response.generated_images[0].image.image_bytes
  part = types.Part.from_bytes(data=image_bytes,
                               mime_type="image/png")
  await tool_context.save_artifact("image.png", part)  # -> version 0
  return {"status": "success", "filename": "image.png"}

root_agent = Agent(name="root_agent",
                   tools=[generate_image, load_artifacts])
Pitfall: base64 media in history is resent every turn. Also, on Agent Runtime AdkApp defaults to InMemoryArtifactService. Pass an artifact_service_builder for GCS.

Live agents stream audio and video both ways and must handle barge-in

Voice budget: a cascaded STT → LLM → TTS pipeline takes ~1.3 s voice-to-voice. Target 1.5 s, and measure mic-to-speaker, not model TTFB.

Pitfall: the client ignores interrupted. Then old audio plays over the user. Flush the player, and call close() in finally.
Input16-bit PCM, 16 kHz mono, 50–100 ms chunks (100 ms = 3,200 bytes). ADK does no transcoding.
OutputPCM 24 kHz. Live models answer in audio only: response_modalities=["AUDIO"]. Text comes from transcription.
VideoJPEG frames, ~1 fps, 768×768. "There is no video codec: a video stream is a sequence of still frames."
Queuesend_content (one text turn) · send_realtime (audio, frames) · send_activity_start/end (manual VAD only) · close()
Eventspartial · turn_complete · interrupted · input/output_transcription · usage_metadata
LimitsConnection ~10 min (GoAway with timeLeft). Without compression: audio 15 min, audio + video 2 min.
Costmax_llm_calls (default 500) does not apply to run_live. Set your own budget.
GraphsA whole Workflow runs in one live connection. Speaking nodes use mode="task".
PhoneLiveKit runner (2.9). CascadeLive STT → LLM → TTS with barge-in (2.12/2.13).
ModelADK docs: gemini-live-2.5-flash-native-audio is DEFAULT_LIVE_MODEL. Gemini catalog: gemini-3.8-live. Read it from an env var.
server.py · adk.dev/live/custom-server (fragments)

run_config = RunConfig(
    response_modalities=["AUDIO"],
    input_audio_transcription=types.AudioTranscriptionConfig(),
    output_audio_transcription=types.AudioTranscriptionConfig(),
    session_resumption=types.SessionResumptionConfig())
live_request_queue = LiveRequestQueue()     # fresh per session
async def upstream():                       # browser -> model
    while True:
        audio_data = await websocket.receive_bytes()
        live_request_queue.send_realtime(types.Blob(
            mime_type="audio/pcm;rate=16000", data=audio_data))
async def downstream():                     # model -> browser
    async for event in runner.run_live(
            user_id=user_id, session_id=session_id,
            live_request_queue=live_request_queue,
            run_config=run_config):
        await websocket.send_text(
            event.model_dump_json(exclude_none=True, by_alias=True))
try:
    await asyncio.gather(upstream(), downstream(),
                         return_exceptions=True)
finally:
    live_request_queue.close()              # always, even on error

Interview drill: choose the right connector, payload and transport

Another team owns billing. Do you connect to it with MCP or A2A?
Ask what they expose. If they expose tools and data that you call as typed functions, use MCP (McpToolset with a tool_filter). If they expose an opaque agent with its own model, memory and policy that takes tasks, use A2A (RemoteA2aAgent on your side, to_a2a on theirs).
Why store a generated image as an artifact, not in state or in the history?
State is small JSON that is written into every event. History sends the bytes again on every turn. An artifact is a versioned Part in GCS. The model sees a filename, and load_artifacts attaches the bytes to one request only when the model needs them.
How do you keep a live voice session running for one hour?
Enable context_window_compression: it removes the 15-minute audio and 2-minute audio + video caps. Enable session_resumption: it survives the ~10-minute connection resets. Handles stay valid for 2 h, and ADK retries up to 5 times. Add your own cost budget, because max_llm_calls does not cap run_live.
Why must the user identity come from trusted state, not from a tool argument?
Prompt injection can change the arguments that the model writes. Read the identity from session state in a before_tool_callback (the validate_tool_params pattern on adk.dev/safety). Block the call when the argument does not match.
Numbers to know 16 kHz in · 24 kHz out · 100 ms = 3,200 B inline ≤ ~20 MB · Files API 2 GB/file, 48 h image 280 → 2,240 tokens A2A v1.0.0 2026-03-12 · MCP 2026-07-28
07
chain router fan-out / join loop tree

Agentic design patterns in ADK 2.0

The 2026 question is not "agent or workflow?". The question is: which parts must be deterministic (money, compliance, routing), and which parts need judgment?

routerparallelorchestrator-workerssubagentsgenerator-verifierdeep agentHITLmemory

Each pattern in this chapter comes with its ADK 2.0 primitive, its token cost and its known failure modes.

Four pattern catalogs name the same few shapes, and ADK 2.0 gives each one a primitive

Start with one LLM call plus retrieval. Go down the tree only when an eval shows that the simpler shape fails.

Overlays on every design

  • Guardrails: plugins, Model Armor
  • HITL on irreversible actions
  • Memory if the task spans sessions
  • Evals always
2026 shift: Claude Code dynamic workflows (2026-06-02) let the agent compose these patterns at runtime: classify-and-act, fan-out-and-synthesize, adversarial verification, tournament, loop-until-done.
ShapeNg · 4 (2024)Anthropic · 5 + 1 (2024)Gulli · 21 (2025)GCP · 12 (2026)ADK 2.0 primitive
Chain—Prompt chainingch. 1SequentialWorkflow(edges=[("START", a, b, c)])
Route—Routingch. 2CoordinatorEvent(route=…) + dict edges, ClassifierNode
Parallel—Parallelizationch. 3ParallelJoinNode, asyncio.gather(ctx.run_node(…))
ReflectReflectionEvaluator-optimizerch. 4Review & critiquecritic node + back-edge
ToolsTool useAugmented LLMch. 5, 10Single-agenttools=[fn, McpToolset, AgentTool]
PlanPlanningOrchestrator-workersch. 6Hierarchical decompositionplanner node + dynamic workers
Multi-agentMulti-agentOrchestrator-workersch. 7, 15Coordinator, Swarmsub_agents with mode, A2A
HITL—(checkpoints)ch. 13Human-in-the-loopRequestInput, require_confirmation
Memory—Augmented LLMch. 8—MemoryService, preload_memory
Guardrails——ch. 18—plugins, Model Armor

Routing and parallel fan-out pay off when the split is cheap to decide

budget_router.py · sketch from documented primitives

import os
from google.adk import Agent, Event, Workflow
from google.adk.workflow import DEFAULT_ROUTE
FAST_MODEL = os.environ["FAST_MODEL"]  # cheap model id
classifier = Agent(name="complexity", model=FAST_MODEL,
    instruction="Reply 'SIMPLE' or 'COMPLEX' only.")
def pick(node_input: str):             # 0 LLM calls
    return Event(route=node_input.strip())
root_agent = Workflow(name="budget_router", edges=[
    ("START", classifier, pick),
    (pick, {"SIMPLE": flash_agent,
            "COMPLEX": pro_agent,
            DEFAULT_ROUTE: pro_agent}),  # unknown label
])

Router failure modes (Gulli ch. 16)

  • A weak router sends hard queries to the cheap model.
  • Teams forget the cost of the router call itself.
  • Tone differs between the two models.
  • Evals pass on the big model and silently regress on the small one. Run the full suite per model.

Parallel failure modes

  • Cost multiplies with N branches. Rate limits hit sooner.
  • The merge cannot reconcile conflicting outputs.
  • Branches race on shared state. Give each branch its own key.
Request
complexityFAST_MODEL
pick()function
flash_agentcheap, fast
pro_agentstrong, slow
cost per call
cost per call
Code-first router. The ambient expense recipe routes on the amount with a plain function: under $100 goes to auto_approve with zero LLM calls. Use a function when data decides (a field, a threshold, a status code). Use an LLM router only for unstructured intent.
0less research time from parallel subagents and tool calls

Mitigations: a DEFAULT_ROUTE, a log of the chosen route per request (BigQuery Agent Analytics plugin), a separate state key per branch.

Orchestrator-workers wins on parallel reads, so keep the writes in one thread

Subagents for context isolation are the dominant 2026 pattern. A child gets a fresh window and returns "a condensed, distilled summary of its work (often 1,000-2,000 tokens)". LangChain (2025-06-10): supervisors lose accuracy when they paraphrase; a forward_message tool and cleaner handoffs gave nearly 50% better performance.

ADK 2.0: wrap workers as AgentTool or as single_turn sub-agents. Each one runs in its own session branch and cannot see its peers. For recursion, call ctx.run_node and stop at MAX_DEPTH in code.

research.py · sketch from documented primitives

@node(rerun_on_resume=True)
async def research(ctx: Context, question: str):
    # planner_agent has output_schema=Plan(steps)
    plan = await ctx.run_node(planner_agent, question)
    findings = await asyncio.gather(*[
        ctx.run_node(worker_agent, step)
        for step in plan.steps])
    return await ctx.run_node(synthesizer_agent,
        {"question": question, "findings": findings})

# worker_agent: include_contents="none", short output_schema
0vs single-agent Opus 4 on an internal research eval
0tokens of a chat
0of BrowseComp variance from tokens alone

Anthropic, 2025-06-13: an Opus 4 lead, 3-5 parallel Sonnet 4 workers, a CitationAgent. Effort rules: fact = 1 agent, 3-10 calls; comparison = 2-4 subagents; complex = more than 10. Failures: 50 subagents for a simple query, duplicated work, endless search.

"multi-agent systems work best today when writes stay single-threaded"

Cognition 2025-06-12 ("Don't Build Multi-Agents"): in the Flappy Bird example, two sub-agents make conflicting implicit decisions. Cognition 2026-04-22: map-reduce-and-manage; Devin Review with a clean context finds about 2 bugs per PR.

Question2026 consensus
Parallel reads?Yes. Fan out readers, compress results.
Parallel writes?No, unless partitioned with a hard verifier.
Context passingDetailed brief in, short summary out.
TopologyOne writer, many advisors.

A generator-verifier loop converges only as fast as its verifier is trustworthy

verified_sql.py · guard node with a routed back-edge

from google.adk import Agent, Event, Workflow
from pydantic import BaseModel
class Draft(BaseModel):
    sql: str
    attempts: int = 0
writer = Agent(name="sql_writer", model=MODEL, output_schema=Draft,
    instruction="Write a BigQuery SQL query. Return only SQL.")
MAX_ATTEMPTS = 3

def sql_guard(node_input: Draft):        # verifier: plain code
    sql = node_input.sql.strip().upper()
    if sql.startswith("SELECT") and "DELETE" not in sql:
        return Event(route="PASS", output=node_input)
    if node_input.attempts + 1 >= MAX_ATTEMPTS:
        return Event(route="GIVE_UP", output=node_input)
    return Event(route="FAIL", output=node_input.model_copy(
        update={"attempts": node_input.attempts + 1}))

root_agent = Workflow(name="verified_sql", edges=[
    ("START", writer, sql_guard),
    (sql_guard, {"PASS": run_query, "FAIL": writer,
                 "GIVE_UP": escalate_to_human})])
# route= + output= in one Event: TS docs show it; check Python

The verifier's rule

"The ease of training AI to solve a task is proportional to how verifiable the task is." A good check is objective, fast, scalable, low-noise and continuous.

Evidence for feedback loops

  • ~20% absolute average gain: Self-Refine, 7 tasks
  • 91% pass@1 on HumanEval: Reflexion, vs 80% for GPT-4
  • 19% → 44% pass@5: AlphaCodium flow, same GPT-4
Failure modes. A sycophantic critic approves round 1. A strict critic never approves. Drafts oscillate. Appended critiques bloat the context. A retry without the failure reason repeats the same failure.

1.x template vs 2.0 graph

The Next '26 codelab uses LoopAgent(max_iterations=3) and an escalation checker. In 2.0 the Judge becomes a router node, and the back-edge is the loop.

0
0

Solo run, Opus 4.5. core game broken

0
0

Planner, generator and a separate evaluator that uses Playwright on the live app. working

Deep agents add a plan, a filesystem and subagents around the loop to work for hours

Deep agent: four parts

  • A detailed system prompt
  • A planning tool, write_todos: its value is recitation
  • Subagents with isolated contexts
  • A filesystem for notes

Manus context rules

  • Recite the plan in todo.md
  • Keep errors in the context
  • About 50 tool calls per task
  • Compress reversibly: keep the URL, drop the page
plan_and_execute.py · sketch from documented primitives

@node(rerun_on_resume=True)
async def plan_and_execute(ctx: Context, goal: str):
    # planner_agent: output_schema=Plan(steps: list[str])
    plan = await ctx.run_node(planner_agent, goal)
    results = []
    for step in plan.steps:
        out = await ctx.run_node(executor_agent, step)
        results.append(out)
    return await ctx.run_node(synthesizer_agent,
        {"goal": goal, "results": results})

Long-horizon harness (Anthropic, 2025-11-26)

Session 1 writes init.sh, claude-progress.txt, a JSON list of 200+ features (all passes:false) and a first commit. Each later session reads the progress file and the git log, runs a smoke test, builds ONE feature, tests it end to end, flips passes and commits. Why JSON: the model is "less likely to overwrite it".

Durable session as a service

Anthropic Managed Agents (2026-04-08) keep the brain (harness), the hands (sandbox) and the session (event log) apart. A crashed harness calls wake(sessionId) and replays the log. Lazy containers cut time to first token:

0p50 (about)
0p95 reduction

The same harness in ADK 2.0

  • Planner node, then a dynamic @node loop over the feature list
  • A verifier node after each feature
  • ResumabilityConfig and rerun_on_resume for durability
  • AgentEngineSandboxCodeExecutor: no network, 100 MB I/O, 14-day state
  • Agent Runtime: long-running operations up to 7 days
  • adk deploy --trigger_sources pubsub,eventarc for ambient agents

Interview drill: pick and justify a pattern for a real product

When does a multi-agent design beat one agent with tools?
For high-value, breadth-first, read-heavy work that splits into parallel parts and has a strong verifier. Expect about 15× the tokens of a chat. Keep the writes in one thread. Anthropic: +90.2% over single-agent Opus 4. Cognition (2026): "writes stay single-threaded".
Design a deep-research agent in ADK 2.0.
A planner node with output_schema → a dynamic fan-out of worker agents with asyncio.gather(ctx.run_node(...)), each with include_contents="none" and a short summary → a synthesizer → a citation or verifier node. Add an optional RequestInput to approve the plan. Bound the depth and max_llm_calls. Keep the plan in state.
How do you stop an LLM router from inventing a branch?
Give the router a Literal[...] field in output_schema. Route with a function on that typed field. Add a DEFAULT_ROUTE catch-all. Or use ClassifierNode with a calibrated threshold.
Why separate the evaluator from the generator?
Self-evaluation is lenient: in Anthropic's harness study, a solo run gave a broken game, and a separate Playwright evaluator gave a working app. A separate, ideally deterministic, verifier gives objective feedback. The loop converges only as fast as its verifier is trustworthy.
08
≤N loop engineering graph engineering harness engineering evals

Loop, graph and harness engineering, verifiers, evals and guardrails

Reliability is engineered: bounded loops, owned edges, verified steps and measured runs.

Evals before features. Assume prompt injection works.

loop engineering graph engineering harness engineering evals

Loop engineering sets six levers, and graph engineering decides which edges code owns

The agent loop is gather context → take action → verify work → repeat. Every agent in this course has this loop at its core.

ralph.sh · Huntley, 2025-07-14

$ while :; do cat PROMPT.md | claude-code ; done

This loop has no stop condition, no budget, no verifier and no escalation. It works only because a person watches it and edits PROMPT.md after each failure.

Loop engineering sets each lever on purpose, in code. Press next to attach the six levers.

LeverADK 2.0 primitive
Stop conditionverifier route pass · terminal route · escalate
BudgetRunConfig.max_llm_calls: default 500, then LlmCallsLimitExceededError; env ADK_MAX_LLM_CALLS (2.8). @node(timeout=). A round counter in state.
Feedback shapeWrite the failure reason to state. Read it with {feedback?} in the generator instruction.
Retry policyRetryConfig for transient faults. ReflectAndRetryToolPlugin and ReflectAndRetryModelPlugin for semantic faults.
EscalationModelConsultTool (2.11, max_uses=2, session_max_uses=5): gemini-3.5-flash asks a stronger model. ForceEscalationPlugin (2.13). RequestInput. abort_signal.
DurabilityCheckpoints · rerun_on_resume · idempotent nodes. Since 2.9 a failed node runs again on resume.

A graph cycle has no built-in max_iterations. You bound it in the router, or in a while loop inside a dynamic @node.

app.py · verified signatures

from google.adk.apps import App
from google.adk.plugins import (
    ReflectAndRetryToolPlugin, ReflectAndRetryModelPlugin)
from google.adk.agents.run_config import RunConfig

app = App(name="coder", root_agent=root_agent, plugins=[
    ReflectAndRetryToolPlugin(max_retries=3),  # tools
    ReflectAndRetryModelPlugin(max_retries=2), # model
])
# run budget, or: export ADK_MAX_LLM_CALLS=60
run_config = RunConfig(max_llm_calls=60)

Retry is not reflection. RetryConfig runs the node again with backoff, and the model sees nothing. The reflect plugins send the error back to the model, so the next call can change.

Real loop bugs in ADK's own CHANGELOG.
2.2: infinite retry in LoadSkillResourceTool on RESOURCE_NOT_FOUND, a non-retriable error sent back as retriable.
2.6: transfer_to_agent ping-pong between agents (2.9 adds transfer_reason).
2.12: a SetModelResponseTool repair loop. 2.13: JoinNode inside a loop, and interrupt_id reused across iterations.

Hard-code the edge if one answer is yes

  1. 1Is the next step data-determined? A field, a threshold or a status code decides it.
  2. 2Does the step have an irreversible side effect? A payment, an email or a deploy.
  3. 3Does compliance need an auditable path?
  4. 4Does a cheap verifier already decide it? Tests pass or fail.
  5. ?The input is unstructured intent: use an LLM router, a ClassifierNode or output_schema with a Literal route.

Allow an autonomous loop only inside one node. The graph bounds its blast radius.

If you can clearly map the workflow, use determinism.

0per run, down from 5,152 (tool-calling agent)
0down from 7.2 s · refund example, gemini-3.5-flash, mock APIs

Put the cheapest reliable verifier after every risky step, and turn every failure into a sensor

A harness is everything around the model. Guides steer the agent before it acts (feedforward). Sensors check its work after it acts (feedback). Each control is computational (code) or inferential (a model).

ComputationalInferential
Guidesoutput_schema, input_schema, state_schema, graph topologyinstruction, static_instruction, Agent Skills (SkillToolset)
Sensorsfunction nodes (lint, tests, SQL dry run), RetryConfig, ToolCallIntegrityPlugincritic nodes, ClassifierNode, ModelArmorPlugin; AutoTracingPlugin for traces

ToolCallIntegrityPlugin (2.11) stamps each function call with "v1:" + HMAC-SHA256 and blocks tampered or replayed calls. It does not check tools that run as workflow nodes: the graph gives their arguments.

It is the idea that anytime you find an agent makes a mistake, you take the time to engineer a solution…

The Hashimoto loop in ADK: each production failure → adk eval_set add_eval_case → a CI gate with adk eval or AgentEvaluator.evaluate. If the same failure comes back, the build fails.

Use the lowest rung that catches the error. Anthropic's Agent SDK post uses the same order: rules, visual feedback, then LLM-as-judge ("not very robust").

Antigravity hooks (dev-time harness): SessionStart PreToolUse PostToolUse PreInvocation PostInvocation Stop.

.agents/hooks.json · sketch, illustrative scripts

{
  "verify-tests": {
    "enabled": true,
    "Stop": [{ "type": "command", "timeout": 120,
               "command": "python .agents/verify.py" }],
    "PreToolUse": [{ "matcher": "run_command",
      "hooks": [{ "command": "python .agents/guard.py" }] }]
  }
}

verify.py runs agents-cli eval run. On failure it prints {"decision": "continue", "reason": "3 eval cases failed"}.

  • PreToolUse prints deny, ask, force_ask or an argument overwrite.
  • Read-only verifier agent (permissionMode: plan): APPROVED / CONDITIONAL / BLOCKED.
  • No built-in PRM in ADK: wrap one as a guard node or a custom metric.

adk eval scores the trajectory and the final answer against a golden set

*.evalset.jsoneval_cases[]
eval caseeval_id
rubrics[]rubric_based_*
scenariouser sim · multi_turn_*
conversation[]final_response (reference)response_match_score
intermediate_datatool_uses[{name, args}]tool_trajectory_avg_score
session_inputapp_name · user_id · state

1 · adk web

Chat with the agent. In the Eval tab, Add current session freezes the tool calls and the answer as the golden case.

2 · adk eval

Run eval-set files or eval-set IDs from the CLI. Do not mix both in one command.

3 · pytest

AgentEvaluator.evaluate in CI. *.test.json = one session, unit style. *.evalset.json = many cases.

Codelab: Evaluating Agents with ADK

$ cat customer_service_agent/test_config.json
{"criteria": {"tool_trajectory_avg_score": 0.8,
              "response_match_score": 0.5}}
$ uv run adk eval customer_service_agent \
    customer_service_agent/evalset1.evalset.json \
    --config_file_path=customer_service_agent/test_config.json \
    --print_detailed_results
$ adk eval agent_dir file.evalset.json:eval_1,eval_2

An agent takes many steps and changes the world. Score the trajectory (which tools, in which order) and the final response.

GroupCriteria (17 on adk.dev/evaluate/criteria)
Trajectorytool_trajectory_avg_score: EXACT (default), IN_ORDER, ANY_ORDER; ignore_args (2.9); default threshold 1.0
Responseresponse_match_score = ROUGE-1, default 0.8 · final_response_match_v2 = LLM judge against the reference
Rubric judgesrubric_based_ + final_response_quality_v1, tool_use_quality_v1, multi_turn_trajectory_quality_v1
Grounding, safetyhallucinations_v1: each segment is supported, unsupported, contradictory, disputed or not_applicable · safety_v1
Multi-turnmulti_turn_task_success_v1, _trajectory_, _tool_use_ (managed) · per_turn_user_simulator_quality_v1
Efficiencytool_call_count_v1, inference_call_count_v1, token_usage_v1, invocation_duration_v1: informational only. A threshold is an error. 2.13 splits duration into model, tool and framework time.
Green but empty. Since 2.13 the adk eval exit code shows pass or fail. Since 2.10 a run with zero cases raises ValueError.

Test the checks. Custom metric temperature_safety fails any set_temperature call outside 18 to 30 °C. Environment simulation (1.24+) injects seeded HTTP 503, latency or wrong data.

Rubric judges, simulated users and pass^k turn evals into a release gate

Eval-driven development: the eval suite is the spec

  • Start with 20 to 50 tasks taken from real failures.
  • Use one vocabulary: task, trial, grader, transcript, outcome.
  • Grade the outcome: the reservation exists in the database. Add trajectory checks only where the path matters.
  • Capability evals start low. Regression evals stay near 100%.
  • Read the transcripts. A 0% pass@100 usually means a broken task, not a weak agent.
0CORE-Bench, Opus 4.5, before grader fixes
0after the grader bugs were fixed

The eval was broken, not the model. Debug the grader before you debug the agent.

pass@k: at least one of k trials succeeds. pass^k: all k trials succeed. At 75% per trial, pass^3 = 0.75³ ≈ 42%. A customer-facing agent needs pass^k.

Rubric judges: judge_model_options: {judge_model, num_samples: 5} samples the judge five times and takes the majority vote. Per-case rubrics merge with criterion rubrics.

eval_config.json · multi_run_config

{
  "criteria": {"tool_trajectory_avg_score": 1.0,
               "response_match_score": 0.8},
  "multi_run_config": {
    "num_runs": 3,
    "cross_run_scoring_by_metric": {
      "response_match_score": "min_over_runs",
      "tool_trajectory_avg_score":
        {"method": "pass_rate", "threshold": 0.9}
    }
  }
}

min_over_runs: every run must pass (pass^k). pass_rate: some flaky runs are allowed. max_crashed_runs defaults to 0.

  • Tier 1, every PR: deterministic checks and trajectory.
  • Tier 2, nightly or on release: rubric judges, multi-run.
  • Tier 3, online: judges on sampled production traffic.

Judge scores are noisy. Gate on the delta against the main baseline (agents-cli eval compare). Keep absolute floors for safety and must-pass cases.

Do not change thresholds and prompts in the same PR.
tests/test_agent_eval.py

import pytest
from google.adk.evaluation.agent_evaluator import AgentEvaluator

@pytest.mark.asyncio
async def test_agent_regression():
    await AgentEvaluator.evaluate(
        agent_module="home_automation_agent",
        eval_dataset_file_path_or_dir=(
            "tests/integration/fixture/home_automation_agent/"),
    )

Simulated users test the conversation, not one turn

  • ConversationScenario = starting_prompt + conversation_plan + user_persona (NOVICE, EXPERT or custom).
  • The simulator stops at its stop signal or at max_allowed_invocations (default 20).
  • Reference-based criteria do not work with simulation. Use task success, rubric and hallucination metrics.
  • tau2-bench adds dual control: the user also has tools. gpt-4.1 loses 18 pp, o4-mini 25 pp.

adk optimize runs GEPA on the eval set: GEPARootAgentPromptOptimizer (default), max_metric_calls 100, reflection_minibatch_size 3. Keep a held-out validation_eval_set. GEPA: +6% against GRPO on average, up to 35× fewer rollouts.

0run offline evals
0run online evals
0name quality the #1 barrier

Design for compromise: limit what a successful prompt injection can do

LLMs are unable to reliably distinguish the importance of instructions based on where they came from.

Lethal trifecta (Willison, 2025-06-16): private data + untrusted content + external communication = exfiltration. Rule of Two (Meta, 2025-10-31): one session gets at most two of [A], [B], [C].

0defences broken
0adaptive attack success
0500 human red-teamers

Detection alone fails, so change the architecture

CaMeL (Google, Google DeepMind, ETH Zurich): a privileged LLM writes a program from the trusted query, a tool-less quarantined LLM parses untrusted data, an interpreter tracks provenance. In ADK a Workflow is plan-then-execute: the LLM classifies, code decides what runs.

callbacks.py · deterministic guard outside the model

def limit_refunds(tool, args, tool_context):
    if tool.name == "refund" and args.get("amount", 0) > 500:
        return {"error": "Refunds > $500 need human approval."}
    return None  # None = let the tool run
agent = Agent(..., before_tool_callback=limit_refunds)
app.py · adk.dev/integrations/model-armor (ADK 2.8+)

from google.adk.integrations.model_armor import (
    ModelArmorConfig, ModelArmorPlugin)
app = App(name="model_armor_demo", root_agent=agent, plugins=[
    ModelArmorPlugin(config=ModelArmorConfig(
        prompt_template_name="projects/my-project/locations/"
            "us-central1/templates/my-prompt-template",
        response_template_name="projects/my-project/locations/"
            "us-central1/templates/my-response-template",
    ))])

ModelArmorPlugin screens prompts and responses and fails closed by default. The templates must be in the same region. Agent Gateway adds egress allow and deny rules.

Know the limits. Model Armor screens one turn at a time. It does not decode Base64 and does not read audio or video. Documents stop at 4 MB. The console Model Armor settings in Gemini Enterprise do not protect ADK agents: configure it in code.
  • ADK hardening: 2.8 fences relayed agent output so it cannot pose as instructions. 2.9: A2A needs https for non-loopback agent cards. 2.11: ToolCallIntegrityPlugin.
  • Supply chain: LiteLLM 1.82.7 and 1.82.8 on PyPI held unauthorized code. Pin and hash every dependency.
  • The OWASP Top 10 for Agentic Applications for 2026 gives a shared threat list for reviews.

Interview drill: prove your agent is reliable and safe

A step fails. Do you use RetryConfig, a routed back-edge or ReflectAndRetryToolPlugin?
RetryConfig for exceptions and transient faults: backoff from 1 s to 60 s, completed children are replayed, and the model sees nothing. A back-edge for semantic failures, where the output is valid but wrong: it carries the verifier's reason as feedback and needs a round counter, because graph cycles are not bounded. ReflectAndRetryToolPlugin for tool errors that the model can fix: it sends reflection_guidance, allows 3 retries by default, and needs an extract_error_from_result override for soft errors like {"status": "error"}.
What is the difference between EXACT, IN_ORDER and ANY_ORDER trajectory matching?
EXACT allows no extra and no missing calls. IN_ORDER needs the expected calls in order but allows extra calls. ANY_ORDER ignores the order. EXACT fails on a harmless extra lookup and makes a better plan look worse, so keep it for strict, regulated flows. Add ignore_args or a rubric tool-use metric when the arguments can vary.
For a customer-support agent, do you report pass@k or pass^k? Why?
pass^k: every attempt must succeed, because each customer gets one attempt. At 75% per trial, pass^3 = 0.75³ ≈ 42%, while pass@3 ≈ 98% looks fine. In ADK, set multi_run_config with num_runs: 3 and min_over_runs, or pass_rate with a threshold when some flakiness is acceptable.
Apply the Rule of Two to an email assistant that reads the inbox and can send mail.
It has all three: untrusted email [A], private data [B] and an external send [C]. Drop one per session: a read-only summarizer, or a send tool limited to allow-listed recipients. If the task needs all three, put a RequestInput approval on each send. Then add layers: ModelArmorPlugin, user identity read from trusted state (never from model arguments), egress policy, and output escaping in the UI.
09
Build Evaluate Deploy Observe Improve AgentOps one loop, run every release rollback traces become tests

Production on Google Cloud and open challenges

AgentOps ships tool-using agents as versioned artefacts: code, prompt, tools, model and config. Trajectory-aware evals gate each release. Traces and cost and latency SLOs operate it, and production failures become new tests.

“a subcategory of GenAIOps that focuses on the efficient operationalization of Agents”
  • Which Google product does what after the 2026 renames?
  • How does an ADK agent go from Antigravity to Gemini Enterprise?
  • Which signals show a failing agent while HTTP still says 200, and what is still unsolved?

Google's agent stack spans a no-code app, a managed platform and a code-first framework

Gemini Enterprise

The employee app. Assistant, connectors and Made-by-Google agents (Deep Research, Co-Scientist, Data Insights). Workflow Builder for no-code agents. Agent Gallery. The Marketplace has 70+ partner agents.

Gemini Enterprise Agent Platform

The developer platform. Four pillars: Build, Scale, Govern, Optimize. Optimize holds evaluation, simulation, observability and prompt optimization. Registry and Gateway GA on 2026-06-18.

Agent Runtime after Next '26

Sub-second cold starts. Long-running operations up to 7 days. WebSocket bidi streaming, custom session IDs and Agent Sandbox. More than 6 trillion tokens a month run on Gemini through ADK.

I/O 2026 framing: four rungs, linked by A2A. Source: Virtualization Review (secondary).

Agent Studiono-code, exports to ADK
Managed Agents APIADK ManagedAgent, 2.4
Antigravity2.0 app + agy CLI, 2026-05-19
ADK 2.0code-first
Preview products can vanish. Gemini Enterprise removed the Idea Generation agent on 2026-07-14. Read product and model IDs from config.

Build with Antigravity and agents-cli, ship to Agent Runtime and publish to Gemini Enterprise

terminal · agents-cli lifecycle (verbatim)

$ uvx google-agents-cli setup
$ agents-cli scaffold create outage-recovery-bot \
    --agent adk --deployment-target agent_runtime
$ agents-cli playground
$ agents-cli eval run
$ agents-cli eval compare prev.json latest.json
$ agents-cli deploy --dry-run
$ agents-cli deploy
$ agents-cli publish gemini
the ADK CLI path · adk.dev/deploy/agent-runtime

$ adk deploy agent_engine \
    --project=$PROJECT_ID --region=us-central1 \
    --display_name="My First Agent" \
    --otel_to_cloud multi_tool_agent

What adk deploy agent_engine does

  • 1 Copies the agent. It honours the .gitignore and .ae_ignore files.
  • 2 Reads .agent_engine_config.json. Put "identity_type": "AGENT_IDENTITY" there.
  • 3 Writes version floors and a Dockerfile: api_server on port 8080 with --a2a.
  • 4 Calls client.runtimes.create(), then update().

SDK path: agentplatform.Client().runtimes.create(…) (SDK 2.0.1). Flags: --trigger_sources, --session_service_uri, --service_account.

Agent Starter Pack is in maintenance mode: “Active development has moved to agents-cli, the next evolution of Agent Starter Pack.” uvx google-agents-cli setup also installs ADK skills into Antigravity CLI, Claude Code and Codex.
AdkApp keeps artifacts in memory by default. A GE app in us accepts only us-* runtimes; eu only europe-*. The agents.create quota (Standard 1, Plus 10 per user per day) returns 429 in CI. A replaced agent gets a new Agent Identity principal: grant the roles again. Source deploys over 8 MB fail.

Where to host

HostPick it for
Agent RuntimeManaged sessions, Memory Bank, identity, A2A, publish to GE, least ops
Cloud RunCustom FastAPI, revision traffic splitting, your own DB
GKEGPUs, self-hosted models, an existing Kubernetes platform

AgentOps watches tokens, tool errors and judge scores, not only HTTP status

Agent SLIs (illustrative SLOs)

Availability 99.5% over 28 days
p99 latency < 5 s (docs alert: 5000 ms)
TTFT p95 < 1.5 s for chat
Tokens per invocation ≤ 1.3× baseline
Tool error rate < 1% per tool
Cost per resolved task business budget
Online judge score ≥ baseline − δ
Model Armor blocks alert on a spike

Canary on Cloud Run (practice built on documented revisions)

  1. gcloud run deploy … --no-traffic --tag cand. The candidate gets its own URL and 0% traffic.
  2. agents-cli eval run --url <tag-url>. Evaluate the live candidate before users reach it.
  3. update-traffic --to-revisions REV=1, then 5, 25 and 100%. Hold each step. Keep sessions sticky.
  4. A guard breaks: --to-revisions PREV=100. Shadow agents need stubbed or read-only tools.
Cost is a reliability metric. A loop does not show up as an error. It shows up as tokens per invocation ×10.

Runbook: detect → triage by trace_id → contain (rollback, FallbackModel, lower ADK_MAX_LLM_CALLS, ForceEscalationPlugin, Model Armor fail-closed) → audit side effects (TOOL_COMPLETED rows) → learn: a new must-pass eval case before the fix ships.

Traces follow the OTel GenAI conventions

  • The conventions now live in open-telemetry/semantic-conventions-genai, status Development. input_tokens includes cached tokens; output_tokens includes reasoning tokens.
  • Switches: --otel_to_cloud on adk web and adk deploy. On Agent Runtime: GOOGLE_CLOUD_AGENT_ENGINE_ENABLE_TELEMETRY=true and OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental. Tracing is on by default for new ADK deployments since 2026-06-18. gen_ai metrics come from ADK 2.6.
  • OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT is opt-in. Get user consent first. agents-cli puts no content in spans and full content in access-controlled GCS and BigQuery.
  • BigQuery agent_events: partitioned by DATE(timestamp), clustered by event_type, agent, user_id. Tool events carry tool_origin. The Storage Write API is paid. Deduplicate on event_id.
Breaking in ADK 2.13: OTel GenAI v2 is the default and the call_llm span is gone. Dashboards keyed on it go blank. Update them, or set ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN=otel_semconv_1_36.
app.py · adk.dev/integrations/bigquery-agent-analytics

# pip install "google-adk[bigquery-analytics]>=2.7.0"
from google.adk.apps import App
from google.adk.plugins.bigquery_agent_analytics_plugin import (
    BigQueryAgentAnalyticsPlugin)

plugin = BigQueryAgentAnalyticsPlugin(project_id="your-gcp-project-id",
                                      dataset_id="your-big-query-dataset-id")
app = App(name="my_agent", root_agent=root_agent, plugins=[plugin])

# daily token SLO per agent (a query over the documented view)
P95_TOKENS = """
SELECT DATE(timestamp) AS day, agent,
  APPROX_QUANTILES(usage_total_tokens, 100)[OFFSET(95)] AS p95_tokens,
  AVG(ttft_ms) AS avg_ttft_ms
FROM `proj.agent_logs.v_llm_response`
GROUP BY day, agent ORDER BY day DESC"""

One view per event type: v_llm_response has usage_total_tokens, ttft_ms and total_ms; v_tool_completed has tool_name, tool_origin and total_ms. The identifiers are verbatim; the SQL is a composition.

Ship only when the checklist holds: simple design, owned context, verified steps, measured runs

Design

  • Written as a workflow first. An eval justifies each agent loop.
  • One agent with tools before multi-agent.
  • Prototype on the most capable model. Then downshift per step with evals.

Instructions

  • Right altitude. Structured (XML or Markdown). Critical items first.
  • Few canonical examples. No contradictions. Current date stated.
  • Prompts versioned and reviewed: “a prompt change is a deploy”.

Tools

  • Few, namespaced tools, shaped around workflows.
  • Readable, paginated returns. Actionable errors.
  • Every tool risk-rated. High risk → confirmation.

State and context

  • Event-sourced and resumable.
  • Scoped state: temp:, session, user:, app:. Artifacts for binaries.
  • Static prefix first. Caching and compaction configured.

Reliability

  • Bounded retries with backoff. A failover model.
  • max_llm_calls and timeouts set.
  • Idempotent tools. No broad except.

Security

  • Rule of Two per session, or HITL.
  • Identity from trusted context, never from model arguments.
  • Layered guardrails. Dependencies pinned.

Evals and ops

  • 20–50 real-failure tasks, outcome-graded, pass^k reported.
  • Capability and regression suites kept apart.
  • OTel tracing, dashboards, eval-gated CI/CD with canary.
  • Pinned model IDs: aliases like gemini-flash-latest drift.

Multimodal

  • Minimum resolution per item.
  • Files API IDs or artifacts inside loops, not base64.
  • Text inside media is untrusted input.
Gemini specifics. Keep temperature at 1.0 on Gemini 3: lower values “may cause looping”. Gemini 4 and 3.6+ ignore temperature, top_p, top_k. Use thinking_level. Never combine it with thinking_budget (HTTP 400).
FAILURE MODEDeclares victory early
FIXJSON feature list + a self-verify step
FAILURE MODERepeats a failed call
FIXCompact errors, change strategy, ADK_MAX_LLM_CALLS
FAILURE MODEDuplicate side effects on resume
FIXIdempotency keys

Long horizons, verification, injection, cost and evaluation drift remain open problems

ADK 2.0harness
1 · Long horizonsMETR 50%: ~1 h → ~17 h OSWorld 2.0 (108 tasks, median human time 1.6 h) tops at 20.6% binary.
2 · Verification16 parallel Claudes “the task verifier is nearly perfect, otherwise Claude will solve the wrong problem”
3 · Prompt injection12 defenses broken, >90% Classifiers are one layer. Guarantees come from architecture (Rule of Two, CaMeL), at a utility cost.
4 · CoordinationCognition: 3 open problems When must a weaker model escalate? How do agents share discoveries? How do you pass context without overload?
5 · Durability costresume replays the log Node checkpoints are written but not read yet, so resume cost grows with history. Versioning for paused runs is open.
6 · Evaluation driftshort benchmarks saturated Judge noise, simulator drift, eval awareness on BrowseComp. ACE and ReasoningBank add memory poisoning and context collapse.
Churn is an open problem too. Docs lag model catalogs (Live and computer-use model IDs). Preview products vanish. Read model IDs from config and pin versions.

Interview drill: run an agent in production on Google Cloud

Agent Runtime, Cloud Run or GKE?
Agent Runtime for managed sessions, Memory Bank, Agent Identity, A2A and publishing to Gemini Enterprise with the least ops. Cloud Run for a custom FastAPI surface and revision traffic splitting. GKE for GPUs or self-hosted models. Cloud Run and GKE can still use managed Sessions and Memory Bank through agentengine:// URIs.
How do you canary a new agent version safely?
Deploy with --no-traffic --tag. Run the eval suite against the tag URL. Shift 1 → 5 → 25 → 100% with session-sticky routing. Watch error rate, p99, tokens per invocation and judge score. Roll back with --to-revisions PREV=100. Never shadow with live write tools.
Name three agent SLIs that a web SRE does not track.
Tokens per invocation, LLM calls per invocation (the loop signature) and an online judge or task-success score. A fourth: tool error rate by tool_origin (LOCAL, MCP, SUB_AGENT, A2A), which blames an upstream MCP server.
What in ADK 2.13 can silently break your monitoring?
OTel GenAI v2 became the default, and the call_llm span was removed. Dashboards and alerts keyed on call_llm go blank, so detection itself fails. Update them to generate_content, or set ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN=otel_semconv_1_36.