The LlmAgent (exported as Agent) is the beating heart of Google’s Agent Development Kit: the one component where an actual language model sits in the loop and decides what to do next. Every other primitive in ADK — the workflow agents, the session and memory services, the runner — exists to feed this class, constrain it, or clean up after it. Where a SequentialAgent is a fixed pipeline you wrote, an LlmAgent is a reasoning process you configured: you hand it an instruction, a model, some tools, and an output contract, and at runtime the model reads its context and chooses to answer, to call a tool, or to hand off to another agent. That non-determinism is the whole point and the whole danger. This article goes deep on the single class — not the taxonomy of agent types, but the knobs on the reasoning agent itself: how the instruction becomes a system prompt with state templated in, how you pick and swap models, how tools get attached and chosen, how the reason–act loop actually turns, how you force structured output, how generation is tuned, how planning and thinking are switched on, and the pitfalls that quietly wreck agents in production.
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.
Callbacks: hooks around the reasoning
An LlmAgent is not a black box you can only configure from the outside; it exposes callback hooks that fire at specific points in the loop. before_model_callback and after_model_callback bracket each model call, letting you inspect or rewrite the request going to the LLM and the response coming back — useful for injecting extra context, redacting sensitive data, enforcing a guardrail, or short-circuiting a call entirely with a canned response. before_tool_callback and after_tool_callback do the same around tool execution.
These hooks are how you add cross-cutting behavior — logging, metrics, input validation, caching, policy enforcement — without tangling it into the instruction or the tools themselves. A before_tool_callback that validates arguments against business rules can reject a malformed or unauthorized call before it ever runs; a before_model_callback can enforce a token budget or strip PII from the outgoing prompt. Because a callback can return a value to replace the step’s normal result, it is also an override mechanism, not just an observer. Used well, callbacks are where the deterministic guarantees your system needs get bolted onto the non-deterministic core the LlmAgent provides.
Controlling context and history
By default an LlmAgent sees the conversation history relevant to its turn, but you can change that with include_contents. Setting it to 'none' makes the agent stateless with respect to prior conversation — it sees only its instruction and the current input, not the back-and-forth that preceded it. That is exactly what you want for a pure transformer agent buried in a pipeline: a classifier or extractor that should act only on the data handed to it this step, uncontaminated by earlier turns.
The choice matters for both correctness and cost. History gives a conversational agent the memory it needs to be coherent across turns, but it also grows the context window every turn, raising latency and token spend and giving the model more opportunity to be distracted by stale detail. A sub-agent invoked to do one clean sub-task is usually better run with include_contents='none' and a precise input, while the user-facing agent keeps history. This is another expression of the article’s recurring theme: an LlmAgent has a handful of levers, and thoughtful defaults on each — model tier, temperature, tool count, history inclusion — separate an agent that works from one that merely demos.
Common pitfalls
The failure modes of LlmAgents are consistent enough to enumerate. The over-stuffed instruction is the most common: a prompt that grows to hundreds of lines of edge cases until the model can no longer tell the important rules from the trivia, and behavior degrades unpredictably. The fix is to move fixed procedure into workflow agents, move validation into callbacks, and keep the instruction to the decisions the model actually has to make.
| Pitfall | Symptom | Fix |
|---|---|---|
| Over-stuffed instruction | Erratic behavior, ignored rules | Trim to core decisions; offload structure to code |
| Tool ambiguity | Wrong tool chosen, invented arguments | Sharpen descriptions; enums; fewer, narrower tools |
| output_schema + tools | Runtime error / silent no-tool behavior | Split into a tool agent and a schema agent |
| Mistyped state key | Empty or literal {key} in prompt | Use {key?} for optional; verify state keys |
| Unbounded loop | Agent calls tools forever, cost spikes | Instruction exit condition; runtime limits |
Tool ambiguity is the second big one: overlapping descriptions or a bloated catalog degrade selection accuracy, and no amount of instruction tuning fully compensates for tools the model cannot cleanly tell apart. And the output_schema-plus-tools conflict catches almost everyone once — remember that structured output and tool use are mutually exclusive within a single agent. Diagnose these at the config level, because that is where they live.
instruction is the system prompt and your primary control surface, with session state templated in via {key} placeholders or an InstructionProvider. model is a hot-swappable choice across Gemini and, through LiteLlm, most other providers. Tools are chosen by the model from their descriptions, and the reason–act loop turns until it converges, which is why exit conditions and actionable tool results matter. output_schema and output_key turn replies into data — but a schema-constrained agent cannot use tools or transfer, so split those roles. Tune generation with generate_content_config, add deliberation with a planner only where structure genuinely varies, bolt determinism on with callbacks, and watch for the classic traps: over-stuffed instructions, tool ambiguity, and the schema-plus-tools conflict. Configure the delegation carefully, and the LlmAgent is the most powerful primitive in the kit.