Lab 01 — LlmAgent, FunctionTool & the Event-driven Runner

Phase 22 · Lab 01 · Phase README · Warmup

The problem

You will never own a Google ADK platform by memorizing runner.run(agent). The interview signal is understanding what that call does. Three objects carry ADK's whole execution model, and this lab builds faithful miniatures of all three:

  1. LlmAgent (aliased Agent) — the reasoning unit: a name, a model (the LLM), an instruction, a description (what other agents read to decide whether to delegate to it), tools, and an output_key that writes the agent's final text into session.state.
  2. FunctionTool — the bridge across the trust boundary: it wraps a plain Python function and derives a JSON schema from its signature (parameter names, type hints, which are required) so the model knows how to call it. The model proposes; the tool executes.
  3. Runner — the loop. It drives the agent and yields a stream of Events: the model's response, each tool call, each tool result, and the final answer. An Event is ADK's unit of "what happened", carrying content and a state delta.

The one trick that makes it all testable: the model is injected as a pure policyCallable[[history], ModelResponse] — so the loop is deterministic and offline. That is exactly how real teams unit-test ADK agents: the control flow is verified independently of the (stochastic, paid, networked) model.

What you build

ObjectWhat it doesThe lesson
FunctionToolwrap a function; derive {name, description, parameters} from its signatureschema quality = tool-use quality; the validator is the trust boundary
ModelResponse / ToolCallthe injected model returns either final text or tool_callsthe model only proposes text; your code executes
LlmAgent / Agentname, model, instruction, tools, output_key, sub_agentsthe reasoning unit and its state-writing seam
Runner.run(...)the agent loop that yields Events and applies the output_key state deltathe ADK execution model, made concrete
Eventmodel_response / tool_call / tool_result / final, with is_final_response()Events are the observability primitive you assert on

File map

FileRole
lab.pyyour implementation (fill the # TODOs)
solution.pyreference implementation + worked example (python solution.py)
test_lab.py24 tests: schema derivation, event stream + ordering, output_key, guards, determinism
requirements.txtpytest only

Run it

pytest test_lab.py -v                       # your lab.py (red until you implement)
LAB_MODULE=solution pytest test_lab.py -v   # the reference (green)
python solution.py                          # worked example

Success criteria

  • Your FunctionTool derives the right schema: typed properties from annotations, required from parameters without defaults, string as the fallback type.
  • A no-tool agent yields a single FINAL event; a tool-using agent yields tool_calltool_resultfinal in that order.
  • output_key writes the final response into session.state, and the final event carries the matching state_delta.
  • A malformed tool call is a structured ToolError, not a TypeError crash; an unknown tool raises; a non-terminating model is stopped by max_steps.
  • All 24 tests pass under both lab and solution.

How this maps to the real stack

  • LlmAgent, Agent, FunctionTool, and Runner are the real class names in google.adk.agents, google.adk.tools, and google.adk.runners. In real ADK the model is a string id ("gemini-2.0-flash") or a BaseLlm; we inject a pure Policy so the loop is deterministic — the same seam ADK teams use to record/replay-test agents.
  • Real ADK FunctionTool really does auto-generate the function declaration from the signature and docstring; this is why idiomatic ADK code uses typed, docstringed tool functions. Our parameters is a simplified JSON-Schema object; ADK emits the fuller Gemini function-calling declaration and also supports LongRunningFunctionTool, agent-as-a-tool, built-in tools (search, code-exec), MCP tools (Phase 03), and OpenAPI tools.
  • The real Runner is async and yields Event objects whose content can hold text, function calls, and function responses in one Content; it also needs a SessionService (Lab 03 builds one) and takes (user_id, session_id, new_message). We flatten Events into explicit typed events (tool_call, tool_result) so the stream is easy to assert on, and use event.is_final_response() exactly as real code does to pull out the answer.
  • output_key writing the final response to session.state[output_key] is the real behavior, and it is the seam every workflow agent in Lab 02 relies on for state flow.

Limits of the miniature. No async/streaming, no real Gemini function-calling protocol, no partial/streamed events, one tool call executed synchronously per proposal, and history is a simple list of dicts rather than ADK's typed Content. The shape — propose → validate → execute → observe → finalize, emitted as an Event stream with a state delta — is faithful.

Extensions (your own machine)

  • Add agent-as-a-tool: wrap an LlmAgent so a parent can call it like a FunctionTool (the child runs its own loop and returns its final text).
  • Support parallel tool calls in one model response (fan out the executions, collect results in order) — ADK models can request several function calls at once.
  • Add streaming: make Runner.run yield partial model_response events as tokens arrive, and a final aggregated event (mirroring ADK's streaming mode).
  • Swap the injected policy for a real Gemini call behind the same Policy signature and confirm the loop is unchanged — the whole point of injecting the model.

Interview / resume signal

"Built a faithful miniature of Google ADK's execution model — LlmAgent + FunctionTool (schema auto-derived from the function signature) + an Event-emitting Runner — with the LLM injected as a pure policy so the agent loop is deterministic and unit-testable, and output_key wiring the final response into scoped session state."