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:
LlmAgent(aliasedAgent) — the reasoning unit: aname, amodel(the LLM), aninstruction, adescription(what other agents read to decide whether to delegate to it),tools, and anoutput_keythat writes the agent's final text intosession.state.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.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 policy —
Callable[[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
| Object | What it does | The lesson |
|---|---|---|
FunctionTool | wrap a function; derive {name, description, parameters} from its signature | schema quality = tool-use quality; the validator is the trust boundary |
ModelResponse / ToolCall | the injected model returns either final text or tool_calls | the model only proposes text; your code executes |
LlmAgent / Agent | name, model, instruction, tools, output_key, sub_agents | the reasoning unit and its state-writing seam |
Runner.run(...) | the agent loop that yields Events and applies the output_key state delta | the ADK execution model, made concrete |
Event | model_response / tool_call / tool_result / final, with is_final_response() | Events are the observability primitive you assert on |
File map
| File | Role |
|---|---|
lab.py | your implementation (fill the # TODOs) |
solution.py | reference implementation + worked example (python solution.py) |
test_lab.py | 24 tests: schema derivation, event stream + ordering, output_key, guards, determinism |
requirements.txt | pytest 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
FunctionToolderives the right schema: typed properties from annotations,requiredfrom parameters without defaults,stringas the fallback type. -
A no-tool agent yields a single
FINALevent; a tool-using agent yieldstool_call→tool_result→finalin that order. -
output_keywrites the final response intosession.state, and the final event carries the matchingstate_delta. -
A malformed tool call is a structured
ToolError, not aTypeErrorcrash; an unknown tool raises; a non-terminating model is stopped bymax_steps. -
All 24 tests pass under both
labandsolution.
How this maps to the real stack
LlmAgent,Agent,FunctionTool, andRunnerare the real class names ingoogle.adk.agents,google.adk.tools, andgoogle.adk.runners. In real ADK the model is a string id ("gemini-2.0-flash") or aBaseLlm; we inject a purePolicyso the loop is deterministic — the same seam ADK teams use to record/replay-test agents.- Real ADK
FunctionToolreally does auto-generate the function declaration from the signature and docstring; this is why idiomatic ADK code uses typed, docstringed tool functions. Ourparametersis a simplified JSON-Schemaobject; ADK emits the fuller Gemini function-calling declaration and also supportsLongRunningFunctionTool, agent-as-a-tool, built-in tools (search, code-exec), MCP tools (Phase 03), and OpenAPI tools. - The real
Runneris async and yieldsEventobjects whosecontentcan hold text, function calls, and function responses in oneContent; it also needs aSessionService(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 useevent.is_final_response()exactly as real code does to pull out the answer. output_keywriting the final response tosession.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
LlmAgentso a parent can call it like aFunctionTool(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.runyield partialmodel_responseevents 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
Policysignature 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-emittingRunner— with the LLM injected as a pure policy so the agent loop is deterministic and unit-testable, andoutput_keywiring the final response into scoped session state."