« 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— aContentofParts: 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")]):
SequentialAgent.run(ctx)iterates its children,yield from-ing each child's Event stream into the samectx.writer.run(ctx)calls its model, produces text, emits a final Event withstate_delta={"draft": "..."}. The Runner applies it →ctx.session.state["draft"]is set.reviewer.run(ctx)runs next over the same ctx. Its instruction template{draft}is resolved againstctx.session.stateat call time, so it literally reads step 1's write. It emitsstate_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:
- Assemble context: instruction (with state templating resolved) + conversation + any tool results accumulated this invocation.
- Call the model. Emit a
model_responseEvent (author = agent.name). - If the response contains function calls: for each, emit a
tool_callEvent, 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 aTypeErrorthat kills the process. Emit atool_resultEvent, append the result to the conversation, loop to step 2. - If the response is final text: attach
state_delta={output_key: text}ifoutput_keyis 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]), ifcheckerescalates on iteration 3,refinerdoes not run a 4th time and nothing aftercheckerin 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
escalateonly 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_iterationsis 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.