« Phase 22 README · Warmup · Hitchhiker's · Deep Dive · Principal Deep Dive · Staff Notes
Phase 22 — Core Contributor Notes: Google Agent Development Kit (ADK)
This is the maintainer's-eye view of the real Google ADK — how the open-source library actually implements the things our stdlib miniature simplifies, the non-obvious design decisions a committer lives with, and the sharp edges you only learn by reading the source. Where an exact internal detail would be a guess, I describe the pattern the framework commits to rather than inventing a specific symbol name.
Schema derivation is introspection, not annotation
Our lab derives a tool schema from a Python signature with a small type map (str→string,
int→integer, and so on). Real ADK does the same thing at production grade: FunctionTool
introspects the wrapped callable — its parameters, type annotations, defaults, and docstring — and
builds a function declaration in the shape the model's function-calling API expects (a Gemini
FunctionDeclaration, or the equivalent when routed through LiteLlm). The consequences a
contributor internalizes:
- The docstring is not documentation, it is the interface. Its first line becomes the tool's
description, and (in the real thing) structured argument docs feed per-parameter descriptions. The model routes on this text. A tool with a vague docstring is a tool the model calls wrongly. - Defaults determine
required. A parameter with no default is required in the declaration; one with a default is optional. This is the same rule the lab implements, and it is why idiomatic ADK tools are typed and defaulted deliberately — the signature is the contract with the model. - Real ADK handles cases the miniature omits:
Optional/union types, Pydantic models as argument types (nested object schemas), and an optional injectedToolContextparameter that is stripped from the declaration — the model never sees it, but the tool body can read state, list artifacts, or request an escalation through it. ThatToolContextinjection is the non-obvious bit: a tool can affect the run (setactions.escalate, write state) without the model knowing the parameter exists.
The async-generator streaming model is the spine
The real Runner and every agent's run_async are async generators that yield Events. This is
the single most consequential implementation decision in the framework. A composite agent runs its
children by async for event in child.run_async(ctx): yield event — it re-yields the child's
stream upward. Our lab uses synchronous yield from for determinism and testability, but the shape
is identical: agents compose by forwarding Event streams, and the Runner sits at the top applying
each Event's state_delta. Because it is a stream, partial model output, tool activity, and final
answers all surface incrementally — which is why adk web can show live reasoning, and why the same
stream becomes traceable spans on Agent Engine. A contributor who understands "it's generators all
the way down" understands why there is no separate scheduler, no explicit agent stack, and no
callback bus: the generator is the control flow.
State is a scoped view that dispatches on prefix
The real State object is a dict-like view that routes reads and writes by prefix to
different backing scopes — session, user:, app:, temp:. The genuinely elegant part, and what
our Lab 03 reproduces: from the agent's perspective there is one session.state mapping;
under it, state["user:tier"] and state["tier"] resolve to different stores. Real ADK layers the
scopes so a read sees the merged view (app + user + session), while a write is routed by the key's
prefix. Two source-level facts worth knowing:
temp:is never persisted. It exists only for the duration of one invocation, as a scratch channel for values you want available to callbacks/tools within a turn but never written to the store. Treatingtemp:as durable is a classic bug — it evaporates at turn's end by design.- State changes flow through
state_deltaon Events, not direct mutation. TheSessionService(InMemorySessionService,DatabaseSessionService,VertexAiSessionService) is the thing that commits deltas and persists them. Swap the implementation and the same prefix routing now writes to SQLite or to Vertex-managed storage — the scoping logic lives in theStateview and the append/commit logic in the service, cleanly separated. This is exactly the seam our miniature keeps.
Workflow agents are BaseAgents that schedule sub_agents
SequentialAgent, ParallelAgent, and LoopAgent are not LlmAgents and hold no model.
They subclass the base agent and override the run method to schedule their sub_agents:
SequentialAgentawaits each child's stream in order over the sharedctx.ParallelAgentruns children concurrently, and — the source-level subtlety — isolates each branch: real ADK runs each child in its own branch of the invocation context so their event streams are distinguishable and their writes don't interleave destructively. The contract is that a branch must not depend on a sibling's output. Our lab enforces the same contract deterministically by snapshotting the input and merging deltas after; the real thing uses genuine concurrency but keeps the same isolation guarantee.LoopAgentre-runs its child sequence until a yielded Event hasactions.escalate=Trueormax_iterationsis reached. Escalation propagates up and stops the loop immediately; the escalating Event is still yielded.
The decision worth appreciating: the framework did not make orchestration a property of the
LLM agent. It made it a separate kind of agent with no reasoning. That is what lets you nest them
arbitrarily — a SequentialAgent whose second child is a LoopAgent whose child is an
LlmAgent — and keep the "deterministic structure, fuzzy leaves" invariant clean.
The description field is the routing contract
For LLM-driven delegation (sub_agents on an LlmAgent + transfer_to_agent), a committer knows
the description field is load-bearing and easy to underestimate. When a coordinator decides
whether to hand control to a child, its model reads the children's descriptions. The transfer is
implemented as an action the model emits (transfer_to_agent naming a child); the framework then
routes control. So the quality of routing is bounded by the quality of the descriptions — a vague or
overlapping set of descriptions produces mis-routing that looks like a model failure but is
actually an API-usage failure. This is the field people leave as an afterthought and then debug for
a day.
LiteLlm, Agent Engine, and the deployment seam
Two integration points define ADK's reach:
LiteLlmis the wrapper that makes ADK model-agnostic in practice. You passmodel=LiteLlm(model="...")and the sameLlmAgentdrives a non-Gemini provider. The framework's loop doesn't care; only the model adapter changes. This is why "ADK is Gemini-only" is wrong — it is Gemini-optimized (native function calling, built-in tools) but genuinely multi-provider.- Vertex AI Agent Engine is the managed runtime: you deploy an ADK agent and get managed
persistent sessions, autoscaling, and tracing without running the infrastructure. The seam that
makes this painless is the service abstraction — production swaps
InMemorySessionServicefor the Vertex-managed one, same agent code. For more control you containerize and run on Cloud Run or GKE.adk web/adk runare the local iteration loop before you ship.
What our miniature deliberately simplifies
Being honest about the gap is the point of building the miniature:
- Deterministic concurrency. Real
ParallelAgentuses true async concurrency; our lab runs branches over an input snapshot and merges deltas. Identical contract, reproducible execution. We trade real parallelism for a test that can't flake — the right call for a teaching lab, the wrong call for production latency. - Injected model policy. We inject the model as a pure
Policyreturning aModelResponse(tool calls or text), so no API key, no network, no nondeterminism. Real ADK calls a live model; the loop around it is what we're proving correct, and that loop is provider-independent. - Scope backing stores. We hold the three tiers in in-memory dicts; real ADK persists them
through a
SessionServiceto a database or Vertex. Same prefix-dispatch semantics, different durability. - The Event surface. Our Event carries what the labs need (author, content,
state_delta,escalate); the real Event has more (artifact deltas, grounding metadata, partial-streaming flags). We model the load-bearing fields, not the full struct.
The through-line: the miniature keeps every contract — Event-driven state deltas, prefix-scoped state, escalate-or-cap loop termination, before-callback short-circuit — and simplifies only the execution substrate. Learn the contracts here and the real source reads as "the same ideas, with concurrency and persistence filled in."