« Phase 22 README · Warmup · Hitchhiker's · Principal Deep Dive · Core Contributor · Staff Notes

Phase 22 — Deep Dive: Google Agent Development Kit (ADK)

The load-bearing idea in ADK is not the LlmAgent and it is not the workflow agents. It is the Event. Everything else — state propagation, workflow composition, callbacks, tracing, deployment — is a consequence of one decision: Runner.run(...) is an async generator that yields immutable Events, and each Event carries a state_delta that the Runner applies to session state as it drains the stream. If you understand the Event loop as a fold over a stream of deltas, the rest of the framework stops being a collection of features and becomes a single mechanism. This doc is about that mechanism.

The Event and the state_delta

An Event is a record of "one thing that happened." Its load-bearing fields:

  • author — which agent (or "user") emitted it. Multi-agent transcripts are just a list of Events with different authors; there is no separate "agent stack" you have to reconstruct.
  • content — a Content of Parts: model text, a function call (name + args), or a function response (the tool result). One uniform container for both directions of the conversation.
  • actions — the control channel. state_delta (a dict merged into session state), escalate (stop the enclosing loop), transfer_to_agent (hand control to a named sibling), and artifact deltas. Control signals ride inside the Event, not beside it.
  • is_final_response() — a computed predicate, not a stored flag. It is true when the Event is a terminal model turn with no outstanding function calls and no partial-streaming marker. Consumers pull the answer with this predicate; they do not count turns.

The critical property: the Runner is the only writer of session state, and it writes by applying state_deltas in stream order. A sub-agent never mutates the session dict directly; it emits an Event whose actions.state_delta requests the write, and the Runner commits it. This is what makes output_key compose (below), what makes callbacks able to intercept a write, and what makes the whole run a replayable log: persist the Event stream and you can reconstruct final state by folding the deltas again. The invariant is "state at step n = seed ⊕ delta₁ ⊕ … ⊕ deltaₙ," and it holds because there is exactly one reducer.

output_key: the threading primitive

output_key looks trivial and is the seam the entire framework hangs on. When an LlmAgent finishes with output_key="draft" set, the final Event it emits carries actions.state_delta = {"draft": final_text}. The Runner applies it. That is the only thing output_key does — and it is enough to make workflow agents compose without a single line of glue, because the workflow agents thread the same session state through their children.

Trace a SequentialAgent([writer(output_key="draft"), reviewer(output_key="review")]):

  1. SequentialAgent.run(ctx) iterates its children, yield from-ing each child's Event stream into the same ctx.
  2. writer.run(ctx) calls its model, produces text, emits a final Event with state_delta={"draft": "..."}. The Runner applies it → ctx.session.state["draft"] is set.
  3. reviewer.run(ctx) runs next over the same ctx. Its instruction template {draft} is resolved against ctx.session.state at call time, so it literally reads step 1's write. It emits state_delta={"review": "..."}.

No message-passing, no return values wired between agents. The shared state dict is the bus, and output_key is the publish operation. This is why forgetting output_key silently breaks a pipeline: step 2's {draft} resolves to empty, the model reviews nothing, and there is no error — the composition seam was never connected.

The Runner loop for a single LlmAgent

The leaf LlmAgent.run is the ReAct loop the Runner drives, and Lab 01 builds it exactly:

  1. Assemble context: instruction (with state templating resolved) + conversation + any tool results accumulated this invocation.
  2. Call the model. Emit a model_response Event (author = agent.name).
  3. If the response contains function calls: for each, emit a tool_call Event, then run the tool. The tool validates arguments against the schema derived from the function signature — missing-required or unknown-arg becomes a structured error Event, not a TypeError that kills the process. Emit a tool_result Event, append the result to the conversation, loop to step 2.
  4. If the response is final text: attach state_delta={output_key: text} if output_key is set, emit the final Event, stop.

A max_steps guard bounds the loop. This is not defensive decoration — it is the mechanism-level answer to a model that never stops requesting tools (or requests the same tool forever). Without the bound, a stochastic proposer with a bad instruction is an unbounded loop. The budget converts "the model might not terminate" into "the run terminates in at most k model calls, guaranteed."

Why the naive alternative fails at the mechanism level: if you let the model's text directly trigger side effects — parse "call get_weather" out of prose and execute it — you have no schema validation seam, no place to hang a before_tool_callback, and no structured error path. ADK's function-call Part is a typed proposal precisely so the trusted side has a well-defined object to validate, intercept, and log. The Event boundary is the security boundary.

LoopAgent termination: the exact semantics and the off-by-one

LoopAgent.run(ctx) repeats its child sequence until either a child emits an Event with actions.escalate=True or max_iterations is hit. The mechanism detail that separates a correct implementation from a subtly broken one is when escalation takes effect:

  • Escalation stops the loop immediately. The escalating child's Event is still yielded (it is a real thing that happened — often it carries the final answer or the "good enough" verdict). But no later child in that same iteration runs. Concretely, in LoopAgent([refiner, checker]), if checker escalates on iteration 3, refiner does not run a 4th time and nothing after checker in iteration 3 runs either.
  • The off-by-one an interviewer probes: does the escalating agent's own event get yielded? Yes. Does the next agent run? No. Implementations that check escalate only at the top of the iteration (not after each child) run one child too many. Implementations that swallow the escalating Event lose the answer. Both are the classic loop-boundary bug.
  • max_iterations is the safety bound for a loop whose escalation depends on a model's judgment. A critic that never decides "good enough" would spin forever; the cap guarantees termination even when the LLM refuses to converge. This is Phase 00's step budget applied to iterative refinement.

ParallelAgent: why the branches must snapshot input

ParallelAgent runs its children against the same input and merges their output_key writes. The mechanism-level hazard is a read-after-write race across branches: because the children run concurrently with no ordering guarantee, if branch B's instruction reads branch A's output_key, whether B sees A's write depends on scheduling. It "works" in dev when A happens to finish first and fails in prod when it does not — a heisenbug born from a data dependency across supposedly independent branches.

The invariant ADK maintains: each branch reads the input state as it was at fan-out, writes its own key, and a downstream step (a SequentialAgent wrapping the ParallelAgent) reads all the branch outputs after the join. The lab makes this testable by modeling concurrency deterministically — run each branch against the same input snapshot, then merge the resulting state deltas — so the contract (no branch sees a sibling's write in its input) is enforced and asserted, not left to a scheduler. That is not weakening the real semantics; it is the real semantics made reproducible. The correctness rule is structural: parallel branches form an antichain in the data-dependency DAG. Any edge between two branches is a bug the topology should have caught.

The whole mechanism in one sentence

Runner.run is a fold: it drains an async generator of immutable Events, applies each Event's state_delta to the single session-state store in stream order, honors escalate/transfer control signals as it goes, and lets before_* callbacks short-circuit a step by returning a substitute result. Workflow agents are just different schedulers over the same child streams — Sequential chains them, Parallel fans them out over a snapshot, Loop repeats them until an escalate delta appears. Master the fold and every ADK feature is a corollary.