The one non-deterministic agent
ADK gives you two families of agents. Workflow agents — SequentialAgent, ParallelAgent, LoopAgent — are deterministic orchestrators: their control flow is code you wrote, and they never call a model to decide what happens next. The LlmAgent is the opposite: its control flow is a model’s decision. Given the conversation, the instruction, and the available tools, the LLM emits either a final answer, one or more tool calls, or a transfer to another agent — and which one it picks is not something you can read off the source code.
That single fact reframes how you build with it. You are not writing an algorithm; you are shaping the inputs to a decision and bounding its consequences. Everything the class exposes — instruction, model, tools, output_schema, generate_content_config, planner — is a lever on that decision or a guardrail around it. The rest of this piece treats each lever in turn. Keep the mental model fixed: the LlmAgent is the place where control is delegated to a model, and good configuration is the art of delegating without losing control.
The instruction is the system prompt
The instruction field is the LlmAgent’s primary control surface. It becomes the system-level guidance the model reads on every turn: the agent’s role, its goals, its constraints, the tone of its replies, and crucially when to use which tool and when to stop. It is the difference between an agent that behaves and one that improvises.
from google.adk.agents import LlmAgent
agent = LlmAgent(
name="support_triage",
model="gemini-2.0-flash",
description="Triages inbound support messages and routes or answers them.",
instruction=(
"You are a support triage agent. Read the user's message and either "
"answer directly for simple questions or call `lookup_order` when the "
"user references an order. Never invent order details. If the request "
"needs a refund, transfer to the `refunds` agent. Keep replies under "
"four sentences."
),
)Note the shape of a good instruction: it names the role, enumerates the decision rules in plain imperative language, states the hard prohibitions (‘never invent’), and specifies the exit condition. A separate global_instruction, set only on the root agent, carries guidance that should apply across an entire agent tree — brand voice, safety rules, a shared persona — so you do not repeat it on every sub-agent. The description field is different again: it is not read by this agent as a prompt but by a parent agent deciding whether to delegate here, so write it as a crisp capability summary, not a personality.
Templating state into the instruction
An instruction is rarely static. ADK lets you interpolate session state directly into the instruction string with brace placeholders, so the prompt the model sees is personalized to the current session without any string-building code of your own. A placeholder like {user_name} is replaced with the value of state["user_name"] at prompt-assembly time; a trailing question mark, {user_tier?}, marks the key optional so a missing value renders empty instead of raising.
agent = LlmAgent(
name="concierge",
model="gemini-2.0-flash",
instruction=(
"You are helping {user_name}, a {user_tier?} customer. "
"Their open tickets: {open_tickets}. Be concise and specific."
),
)When the templating rules outgrow simple substitution — you need conditionals, loops, or values computed on the fly — pass an InstructionProvider instead of a string: a callable that receives a read-only context and returns the finished instruction. That gives you the full power of Python to build the prompt while still reading live session state. The discipline here is to template bounded, trusted values. Interpolating a large or attacker-influenced blob straight into the system instruction is how prompt injection climbs from user data into your agent’s governing rules; keep templated state small, named, and sanitized.
Choosing the model: Gemini and beyond
The model parameter accepts either a string identifier or a model object. The string form is the common case and selects a Gemini variant — for example a fast, cheap tier for high-volume routing and a stronger tier for hard reasoning. The choice is a direct latency-cost-capability trade: a triage agent that mostly classifies wants the fast model, while an agent that writes multi-step analyses earns the more capable one.
fast = LlmAgent(name="router", model="gemini-2.0-flash", instruction="...")
smart = LlmAgent(name="analyst", model="gemini-2.5-pro", instruction="...")ADK is deliberately model-agnostic. To run a non-Gemini model you wrap it with the LiteLlm adapter, which speaks a common interface to dozens of providers (OpenAI, Anthropic, local models via Ollama, and others) so the rest of your agent code does not change.
from google.adk.models.lite_llm import LiteLlm
agent = LlmAgent(
name="analyst",
model=LiteLlm(model="openai/gpt-4o"), # any LiteLLM-supported id
instruction="...",
)The practical caveat: capabilities are not uniform across models. Native ‘thinking’, function-calling fidelity, and controlled-generation support vary, so an agent that relies on those features may behave differently when you swap the backend. Treat the model as a hot-swappable dependency, but re-run your evals after a swap rather than assuming parity.
Attaching tools and how the model decides to call them
You give an LlmAgent capabilities by passing callables (or toolset objects) in tools=[...]. From the LlmAgent’s vantage, the important thing is not how a Python function becomes a declaration — that extraction and the deeper toolset machinery are their own subject — but that the model chooses among your tools by reading their names, descriptions, and parameter schemas. Those descriptions are part of the prompt the model reasons over, every turn.
def lookup_order(order_id: str) -> dict:
"""Fetch an order by its 8-digit numeric ID from the confirmation email."""
...
agent = LlmAgent(
name="support",
model="gemini-2.0-flash",
instruction="Answer order questions. Use lookup_order when an order is named.",
tools=[lookup_order],
)So tool selection is a language problem before it is an engineering one. If two tools have overlapping descriptions, the model guesses; if a parameter is a free-form string where an enum would do, the model invents values. The LlmAgent-level levers that keep selection accurate are the ones you control here: keep the catalog small (routing accuracy falls as tool count climbs), make each description say when not to use the tool, and let the instruction arbitrate genuinely ambiguous cases. When a catalog must be large, splitting it across sub-agents — each an LlmAgent with a narrow, unambiguous tool set — usually beats overloading one agent.
The reason–act loop
A single user turn is not a single model call. Once an LlmAgent has tools, it runs an internal reason–act loop: the model reasons, optionally emits tool calls, the runtime executes them and feeds the results back, and the model reasons again with those results in context — repeating until it produces a final answer with no further tool calls. What you see as one reply may be several model invocations and several tool round-trips under the hood.
This loop is why the instruction must specify an exit condition and why tool results must be model-actionable. Each iteration re-reads the growing context, so a tool that dumps 50KB of JSON poisons every subsequent step, and an error returned as an opaque stack trace leaves the model with nothing to recover from — return ‘inventory service timeout; safe to retry’ and it can act. The loop is also where cost and latency accumulate: N tool round-trips mean N+1 model calls. Practically, you bound it — through the runtime’s limits and through instructions that discourage needless tool use — because an agent that keeps calling tools without converging is the most common way a reasoning agent runs away. ADK surfaces every step of this loop as a stream of events, which is what makes the otherwise-opaque reasoning observable and debuggable.
Structured output: output_schema and output_key
Free text is fine for a chat reply and useless when another system must consume the result. Two fields turn an LlmAgent’s output into data. output_key stores the agent’s final response into session state under the given key, so a downstream agent or workflow step can read it — the standard way agents pass results to one another. output_schema goes further: you give it a Pydantic model and the agent is constrained to emit JSON matching that schema.
from pydantic import BaseModel, Field
class Triage(BaseModel):
category: str = Field(description="billing | shipping | technical | other")
urgency: int = Field(ge=1, le=5)
needs_human: bool
agent = LlmAgent(
name="classifier",
model="gemini-2.0-flash",
instruction="Classify the support message.",
output_schema=Triage,
output_key="triage_result",
)There is a critical constraint that trips up newcomers: setting output_schema puts the agent in controlled-generation mode, which disables tool use and disables transferring to other agents. An agent that must both call tools and return structured data cannot do both in one LlmAgent — the usual pattern is one tool-using agent that gathers information and writes it to state, then a second schema-constrained agent that reads that state and emits the final structured object.
Input and output schemas as a contract
output_schema has a mirror, input_schema, and together they let an LlmAgent behave like a typed function rather than a free-form chatbot. input_schema declares the structure the agent expects its input to conform to; output_schema declares the structure it must produce. When both are set, the agent is effectively a schema‑in, schema‑out transformer — ideal for a stage buried inside a larger pipeline where you want deterministic-looking boundaries around a non-deterministic core.
This is where LlmAgents compose cleanly. A SequentialAgent can chain several LlmAgents, each writing its structured result to state via output_key and the next reading it — the schemas document and enforce the contract at each hand-off, so a malformed intermediate result fails loudly instead of silently corrupting the next stage. The trade-off is rigidity: a schema-constrained agent cannot use tools or delegate, and it will contort its answer to fit the schema even when the honest answer does not fit. Reach for schemas at the edges of your system, where data must cross a boundary, and leave the interior agents free-form where reasoning flexibility matters more than a clean type.
Tuning generation: temperature, tokens, safety
Beyond what the model is asked, you control how it generates through generate_content_config, which accepts a GenerateContentConfig from the underlying GenAI types. This is where you set sampling temperature, nucleus sampling (top_p), the output-token ceiling, stop sequences, and safety settings.
from google.genai import types
agent = LlmAgent(
name="extractor",
model="gemini-2.0-flash",
instruction="Extract the invoice fields exactly as written.",
generate_content_config=types.GenerateContentConfig(
temperature=0.0, # deterministic-ish for extraction
top_p=0.95,
max_output_tokens=1024,
safety_settings=[
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=types.HarmBlockThreshold.BLOCK_ONLY_HIGH,
),
],
),
)The temperature choice tracks the task: near 0.0 for extraction, classification, and tool-routing where you want stable, repeatable decisions; higher for brainstorming or drafting where variety helps. max_output_tokens is a real safety valve against runaway generation and its cost, and safety settings let you tune the model’s content filters per harm category — loosening them for a legitimate domain (say, medical or security content that trips default filters) or tightening them for a public-facing agent. These settings apply to every model call the agent makes inside its reason–act loop.
Planning and thinking
For tasks with real structure, an LlmAgent can be given a planner so it deliberates before — and while — it acts. From the config vantage there are two knobs. A BuiltInPlanner switches on a model’s native thinking capability (for models that have one, such as the Gemini 2.5 tier) by passing a ThinkingConfig, letting the model spend internal reasoning tokens on hard problems. A PlanReActPlanner takes the other route: it works with models that lack native thinking by structuring the prompt and output into explicit planning, reasoning, and action phases.
from google.adk.planners import BuiltInPlanner
from google.genai import types
agent = LlmAgent(
name="researcher",
model="gemini-2.5-pro",
instruction="Answer multi-step research questions using the tools.",
planner=BuiltInPlanner(
thinking_config=types.ThinkingConfig(include_thoughts=True),
),
tools=[...],
)The judgement call is when to spend planning tokens at all. Planning earns its cost only where the decomposition genuinely depends on the specific task; where the steps are fixed and known, encoding them as a workflow agent is cheaper, deterministic, and more debuggable than making the model re-plan them every turn. The mature pattern is hybrid — deterministic structure around a planning LlmAgent for the parts that truly vary — and the deeper mechanics of replanning and reflection are a topic of their own.