« 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 (strstring, intinteger, 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 injected ToolContext parameter 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. That ToolContext injection is the non-obvious bit: a tool can affect the run (set actions.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. Treating temp: as durable is a classic bug — it evaporates at turn's end by design.
  • State changes flow through state_delta on Events, not direct mutation. The SessionService (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 the State view 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:

  • SequentialAgent awaits each child's stream in order over the shared ctx.
  • ParallelAgent runs 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.
  • LoopAgent re-runs its child sequence until a yielded Event has actions.escalate=True or max_iterations is 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:

  • LiteLlm is the wrapper that makes ADK model-agnostic in practice. You pass model=LiteLlm(model="...") and the same LlmAgent drives 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 InMemorySessionService for the Vertex-managed one, same agent code. For more control you containerize and run on Cloud Run or GKE. adk web/adk run are 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 ParallelAgent uses 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 Policy returning a ModelResponse (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 SessionService to 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."