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.
A graph is an agent.
Every chapter ends with an interview drill. Codelabs are cited with their exact titles.
The loop is still a loop. What changed is everything around it.
History, one line: in 2022 ReAct put Thought → Action → Observation in one growing prompt. Today that loop is only the outer skeleton.
| Harness part | ADK 2.0 home |
|---|---|
| Model + loop | LlmAgent with tools |
| Tools | FunctionTool, McpToolset |
| Context policy | include_contents, compaction, caching |
| Session log | session events, checkpoint and resume |
| Sandbox | hermetic code execution |
| Verifiers | output_schema, function nodes, critics |
| Human | RequestInput, require_confirmation |
| Triggers | Pub/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.
HumanEval, Andrew Ng, The Batch 2024-03-20
Workflow: “systems where LLMs and tools are orchestrated through predefined code paths”
Agent: “systems where LLMs dynamically direct their own processes and tool usage”
Google: “If you can clearly map the workflow, use determinism.” The numbers are illustrative: refund task, gemini-3.5-flash, mock APIs.
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),
]
)
“Loop engineering” and “graph engineering” are practice labels in this deck, not named publications.
| Discipline | Question it answers | Who named it | ADK 2.0 home |
|---|---|---|---|
| Flow engineering | What sequence of LLM and test steps solves this task class? | Ridnik, Kredo, Friedman: AlphaCodium, arXiv 2401.08500, 2024-01-16 | Workflow edges, dynamic @node |
| Context engineering | What 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 engineering | When 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 sensors | plugins, eval sets as regression tests, RetryConfig |
| Loop engineering | When does the loop stop, retry, reflect or escalate, and what does it cost? | No single verified coiner. Roots: Huntley's “Ralph” loop, 2025-07-14 | max_llm_calls, RetryConfig, ReflectAndRetry*Plugin, ModelConsultTool, abort_signal |
| Graph engineering | Which 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-01 | Workflow edges, Event(route=), ClassifierNode |
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.”
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.
LlmAgent holds sub_agents. The model picks the next agent with transfer_to_agent.START, function nodes and routers sit between the agents. A return value is the next node's input.pip install "google-adk~=1.0" (1.39.1). 2.x needs Python 3.11+.
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)],
)
$ pip install google-adk
$ adk run path/to/my_agent
$ adk web path/to/agents_dir
$ 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
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.”
gemini-3.5-flash, gemini-3.8-flash, gemini-2.5-flash.
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)
state_delta.session.state on a session fetched outside an invocation: it “will likely NOT be saved”.| Prefix | Scope | Persisted |
|---|---|---|
| (none) | this session | Database / Vertex |
user: | all sessions of this user in this app | Database / Vertex |
app: | all users and sessions of the app | Database / Vertex |
temp: | this invocation, with its sub-agents | never |
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 callbacks | Plugins | |
|---|---|---|
| Scope | one agent object | every agent, model and tool |
| Order | after the plugins | first; non-None skips the rest |
| Config | YAML *_callbacks keys | App(plugins=[…]), --extra_plugins |
| Use for | per-agent logic, state seeding | security guardrails and policies |
before_model_callback do?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.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.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.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.“Workflows separate execution routing from language processing.”
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)])
node_input against input_schema. Dicts are coerced into the model.Agent, a BaseTool or a nested Workflow.None is dropped, an Event passes, a RequestInput becomes an interrupt, any other value becomes Event(output=value).output | Data for the next node. Only one per node run. |
message | Content that the user sees. |
state | A delta that the session saves. |
route | The 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 constructionSTART is present, with no incoming edges and no routes.output_schema equals the successor's input_schema (often from type hints).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.WorkflowConfigurationError. Put a JoinNode before the end.NodeLike: a function, an Agent, a tool or a Workflow.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.
Literal field, then a function routes.ClassifierNode (2.13, labs): a score and a threshold.
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
])
"REFUND". With no default edge, the request stops with no error. Constrain the label with a Literal schema and add DEFAULT_ROUTE.
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).
JoinNode sets _requires_all_predecessors → True. It emits one dict keyed by predecessor node name.(START, (word_count, shout), join, report). Then report reads node_input['shout'] and node_input['word_count'].multi_triggers).@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.JoinNode.Workflow(max_concurrency=2) caps graph-scheduled nodes only. ctx.run_node children are exempt: “throttling them would cause deadlock”.
Fixes: 2.8 counts START as a satisfied join predecessor. 2.13 makes a JoinNode in a loop wait for each iteration's predecessors.
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()
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.”
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.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.
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.
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.
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}),
])
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")
asyncio.gather are ordinary Python. A parent that calls ctx.run_node must set rerun_on_resume=True.adk web. Dynamic: data-dependent loops and recursion.depth < MAX_DEPTH."1", "2", … These ids are the checkpoint keys.run_id must contain a non-numeric character. The docs discourage custom ids.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.
tasks = [ctx.run_node(real_node, item) for item in node_input]
return await asyncio.gather(*tasks) # resume: failed ones only
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).
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) | task | single_turn | |
|---|---|---|---|
| User turns | full | clarification only | none |
| Return | manual transfer_to_agent | automatic finish_task | automatic, with result |
| Parallel | no | no | yes |
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, b] is a fan-out.Event(route=...)..agent.fn is a Python function. x.yaml is an agent file.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.
yaml.safe_load only.*_code and *_callbacks reference is checked against a denylist: the entire stdlib, plus yaml and ruamel.args denylist is off by default.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.
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)
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
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.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).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.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.Long runs must pause for people, survive crashes and retry safely.
“any node can pause the graph and ask a human a question”
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.
@node(require_confirmation=…) 2.12RequestInput 2.11FunctionTool(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.
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.ctx.run_node children. Completed children replay.parallel_worker retries per item.max_retries=3 gives 4 attempts in total.extract_error_from_result to catch {"status": "error"}.App(plugins=[...]).
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"
try:
data = call_api()
except Exception: # masks the failure: retries never fire
return {"status": "error"}
except BaseException: ... # traps NodeInterruptedError: breaks HITL
@node(timeout=30.0) raises NodeTimeoutError, which is retryable. abort_signal stops a run (2.11). FallbackModel fails over to another model (2.9).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=Trueagent_state / end_of_agent checkpoint events in the log.
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)
$ 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"}'
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).
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?ctx.resume_inputs. A parent that calls ctx.run_node needs True. require_confirmation turns True on for you.require_confirmation gate on high-risk nodes, and do not change the graph while runs are paused.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.Context is a compiled view over a richer stateful system.
The goal is "the smallest possible set of high-signal tokens that maximize the likelihood of some desired outcome."
| Strategy | ADK primitives |
|---|---|
| Write | output_key · Event(state=…) · ArtifactService · MemoryService |
| Select | {key} / {key?} · <Class.field from node> · preload_memory / load_memory · LoadArtifactsTool · Jinja2 (2.7) |
| Compress | EventsCompactionConfig · ContextFilterPlugin |
| Isolate | include_contents='none' single_turn isolation_scope node_input |
Chroma tested 18 LLMs. Accuracy drops as the input grows, even on simple tasks. n tokens give n² attention pairs (Anthropic).
A hallucination enters the context and gets re-cited.
Long history outweighs what the model learned in training.
Irrelevant material, such as too many tools, steers the output.
Parts of the context contradict each other.
Google ADK: "the Session is your ground truth; the working context is merely a computed projection."
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.
node_input. Tool outputs do not pile up in one context, so the architecture does the isolation.
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.
InMemoryMemoryService: keyword matchVertexAiMemoryBankService: managedVertexAiRagMemoryService: RAG corpusSqliteMemoryService (2.11)MongoDbMemoryService (2.13)delete_memory() (2.13)
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
user_id, set TTLs, and purge.
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"
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])
---
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>`
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.preload_memory, and when load_memory?add_session_to_memory, usually in after_agent_callback.static_instruction and dynamic data in the tail of the context."MCP standardizes how an agent connects to tools… A2A standardizes how independent agents discover each other, delegate tasks, and share results."
asana_projects_search.validate_tool_params).{"status": "error", ...} instead. The model reads the dict and can try again.
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 · agent → tools | A2A · agent → agent | |
|---|---|---|
| Peer | Transparent, schema-typed functions | Opaque agent: own model, memory, policy |
| Discovery | list_tools · server/discover (2026-07-28) | Agent Card: /.well-known/agent-card.json |
| Unit · wire | Tool call · stdio or Streamable HTTP | Task, Message, Part, Artifact · JSON-RPC, gRPC, HTTP+JSON |
| ADK | McpToolset · to_mcp_server (2.5) | RemoteA2aAgent · to_a2a · Experimental |
| Spec | 2026-07-28: stateless, MRTR. AAIF (Linux Foundation) since 2025-12-09 | v1.0.0 (2026-03-12): JWS-signed cards. 150+ orgs |
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}")
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.
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.
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])
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.
interrupted. Then old audio plays over the user. Flush the player, and call close() in finally.| Input | 16-bit PCM, 16 kHz mono, 50–100 ms chunks (100 ms = 3,200 bytes). ADK does no transcoding. |
| Output | PCM 24 kHz. Live models answer in audio only: response_modalities=["AUDIO"]. Text comes from transcription. |
| Video | JPEG frames, ~1 fps, 768×768. "There is no video codec: a video stream is a sequence of still frames." |
| Queue | send_content (one text turn) · send_realtime (audio, frames) · send_activity_start/end (manual VAD only) · close() |
| Events | partial · turn_complete · interrupted · input/output_transcription · usage_metadata |
| Limits | Connection ~10 min (GoAway with timeLeft). Without compression: audio 15 min, audio + video 2 min. |
| Cost | max_llm_calls (default 500) does not apply to run_live. Set your own budget. |
| Graphs | A whole Workflow runs in one live connection. Speaking nodes use mode="task". |
| Phone | LiveKit runner (2.9). CascadeLive STT → LLM → TTS with barge-in (2.12/2.13). |
| Model | ADK docs: gemini-live-2.5-flash-native-audio is DEFAULT_LIVE_MODEL. Gemini catalog: gemini-3.8-live. Read it from an env var. |
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
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).load_artifacts attaches the bytes to one request only when the model needs them.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.before_tool_callback (the validate_tool_params pattern on adk.dev/safety). Block the call when the argument does not match.The 2026 question is not "agent or workflow?". The question is: which parts must be deterministic (money, compliance, routing), and which parts need judgment?
Each pattern in this chapter comes with its ADK 2.0 primitive, its token cost and its known failure modes.
Start with one LLM call plus retrieval. Go down the tree only when an eval shows that the simpler shape fails.
| Shape | Ng · 4 (2024) | Anthropic · 5 + 1 (2024) | Gulli · 21 (2025) | GCP · 12 (2026) | ADK 2.0 primitive |
|---|---|---|---|---|---|
| Chain | — | Prompt chaining | ch. 1 | Sequential | Workflow(edges=[("START", a, b, c)]) |
| Route | — | Routing | ch. 2 | Coordinator | Event(route=…) + dict edges, ClassifierNode |
| Parallel | — | Parallelization | ch. 3 | Parallel | JoinNode, asyncio.gather(ctx.run_node(…)) |
| Reflect | Reflection | Evaluator-optimizer | ch. 4 | Review & critique | critic node + back-edge |
| Tools | Tool use | Augmented LLM | ch. 5, 10 | Single-agent | tools=[fn, McpToolset, AgentTool] |
| Plan | Planning | Orchestrator-workers | ch. 6 | Hierarchical decomposition | planner node + dynamic workers |
| Multi-agent | Multi-agent | Orchestrator-workers | ch. 7, 15 | Coordinator, Swarm | sub_agents with mode, A2A |
| HITL | — | (checkpoints) | ch. 13 | Human-in-the-loop | RequestInput, require_confirmation |
| Memory | — | Augmented LLM | ch. 8 | — | MemoryService, preload_memory |
| Guardrails | — | — | ch. 18 | — | plugins, Model Armor |
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
])
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.Mitigations: a DEFAULT_ROUTE, a log of the chosen route per request (BigQuery Agent Analytics plugin), a separate state key per branch.
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.
@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
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.
| Question | 2026 consensus |
|---|---|
| Parallel reads? | Yes. Fan out readers, compress results. |
| Parallel writes? | No, unless partitioned with a hard verifier. |
| Context passing | Detailed brief in, short summary out. |
| Topology | One writer, many advisors. |
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 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.
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.
Solo run, Opus 4.5. core game broken
Planner, generator and a separate evaluator that uses Playwright on the live app. working
write_todos: its value is recitationtodo.md
@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})
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".
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:
@node loop over the feature listResumabilityConfig and rerun_on_resume for durabilityAgentEngineSandboxCodeExecutor: no network, 100 MB I/O, 14-day stateadk deploy --trigger_sources pubsub,eventarc for ambient agentsoutput_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.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.Evals before features. Assume prompt injection works.
The agent loop is gather context → take action → verify work → repeat. Every agent in this course has this loop at its core.
$ 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.
| Lever | ADK 2.0 primitive |
|---|---|
| Stop condition | verifier route pass · terminal route · escalate |
| Budget | RunConfig.max_llm_calls: default 500, then LlmCallsLimitExceededError; env ADK_MAX_LLM_CALLS (2.8). @node(timeout=). A round counter in state. |
| Feedback shape | Write the failure reason to state. Read it with {feedback?} in the generator instruction. |
| Retry policy | RetryConfig for transient faults. ReflectAndRetryToolPlugin and ReflectAndRetryModelPlugin for semantic faults. |
| Escalation | ModelConsultTool (2.11, max_uses=2, session_max_uses=5): gemini-3.5-flash asks a stronger model. ForceEscalationPlugin (2.13). RequestInput. abort_signal. |
| Durability | Checkpoints · 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.
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.
LoadSkillResourceTool on RESOURCE_NOT_FOUND, a non-retriable error sent back as retriable.transfer_to_agent ping-pong between agents (2.9 adds transfer_reason).SetModelResponseTool repair loop. 2.13: JoinNode inside a loop, and interrupt_id reused across iterations.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.
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).
| Computational | Inferential | |
|---|---|---|
| Guides | output_schema, input_schema, state_schema, graph topology | instruction, static_instruction, Agent Skills (SkillToolset) |
| Sensors | function nodes (lint, tests, SQL dry run), RetryConfig, ToolCallIntegrityPlugin | critic 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.
{
"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.permissionMode: plan): APPROVED / CONDITIONAL / BLOCKED.Chat with the agent. In the Eval tab, Add current session freezes the tool calls and the answer as the golden case.
Run eval-set files or eval-set IDs from the CLI. Do not mix both in one command.
AgentEvaluator.evaluate in CI. *.test.json = one session, unit style. *.evalset.json = many cases.
$ 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.
| Group | Criteria (17 on adk.dev/evaluate/criteria) |
|---|---|
| Trajectory | tool_trajectory_avg_score: EXACT (default), IN_ORDER, ANY_ORDER; ignore_args (2.9); default threshold 1.0 |
| Response | response_match_score = ROUGE-1, default 0.8 · final_response_match_v2 = LLM judge against the reference |
| Rubric judges | rubric_based_ + final_response_quality_v1, tool_use_quality_v1, multi_turn_trajectory_quality_v1 |
| Grounding, safety | hallucinations_v1: each segment is supported, unsupported, contradictory, disputed or not_applicable · safety_v1 |
| Multi-turn | multi_turn_task_success_v1, _trajectory_, _tool_use_ (managed) · per_turn_user_simulator_quality_v1 |
| Efficiency | tool_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. |
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.
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.
{
"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.
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.
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/"),
)
ConversationScenario = starting_prompt + conversation_plan + user_persona (NOVICE, EXPERT or custom).max_allowed_invocations (default 20).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.
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].
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.
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)
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.
ToolCallIntegrityPlugin.RetryConfig, a routed back-edge or ReflectAndRetryToolPlugin?reflection_guidance, allows 3 retries by default, and needs an extract_error_from_result override for soft errors like {"status": "error"}.EXACT, IN_ORDER and ANY_ORDER trajectory matching?ignore_args or a rubric tool-use metric when the arguments can vary.multi_run_config with num_runs: 3 and min_over_runs, or pass_rate with a threshold when some flakiness is acceptable.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.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.
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.
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.
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).
$ 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
$ adk deploy agent_engine \
--project=$PROJECT_ID --region=us-central1 \
--display_name="My First Agent" \
--otel_to_cloud multi_tool_agent
adk deploy agent_engine does.gitignore and .ae_ignore files..agent_engine_config.json. Put "identity_type": "AGENT_IDENTITY" there.api_server on port 8080 with --a2a.client.runtimes.create(), then update().SDK path: agentplatform.Client().runtimes.create(…) (SDK 2.0.1). Flags: --trigger_sources, --session_service_uri, --service_account.
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.| Host | Pick it for |
|---|---|
| Agent Runtime | Managed sessions, Memory Bank, identity, A2A, publish to GE, least ops |
| Cloud Run | Custom FastAPI, revision traffic splitting, your own DB |
| GKE | GPUs, self-hosted models, an existing Kubernetes platform |
gcloud run deploy … --no-traffic --tag cand. The candidate gets its own URL and 0% traffic.agents-cli eval run --url <tag-url>. Evaluate the live candidate before users reach it.update-traffic --to-revisions REV=1, then 5, 25 and 100%. Hold each step. Keep sessions sticky.--to-revisions PREV=100. Shadow agents need stubbed or read-only tools.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.
open-telemetry/semantic-conventions-genai, status Development. input_tokens includes cached tokens; output_tokens includes reasoning tokens.--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.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.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.
# 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.
temp:, session, user:, app:. Artifacts for binaries.max_llm_calls and timeouts set.except.gemini-flash-latest drift.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).ADK_MAX_LLM_CALLSagentengine:// URIs.--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.tool_origin (LOCAL, MCP, SUB_AGENT, A2A), which blames an upstream MCP server.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.