Problem: Gemini 2.0 Gives You Text — You Need Actions
Getting Gemini to answer questions is easy. Getting it to reliably call your code, validate arguments, and chain multiple tools in one turn is where most tutorials stop.
This guide shows four production-ready patterns: a single tool call, parallel tool calls, multi-turn chaining, and error handling when the model passes bad arguments.
You'll learn:
- How to define tools Gemini 2.0 actually calls reliably
- How to handle parallel and sequential tool calls in one response
- How to feed tool results back into the conversation correctly
Time: 20 min | Difficulty: Intermediate
Why Function Calling Fails in Practice
Gemini's function calling works differently from simple chat. The model doesn't execute your code — it returns a structured function_call object. Your code runs the function, then sends the result back. If you skip that loop, the model never finishes.
Common failure modes:
- Passing the result as a
usermessage instead of atoolmessage - Ignoring
finish_reason: STOPvsfinish_reason: TOOL_CALLS - Defining schemas with optional fields the model misses entirely
Setup
# Requires Python 3.11+
pip install google-genai
import os
from google import genai
from google.genai import types
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
MODEL = "gemini-2.0-flash"
Get your API key at Google AI Studio. The gemini-2.0-flash model handles function calling at low latency and cost — use gemini-2.0-pro if you need stronger reasoning for complex schemas.
Example 1: Single Tool Call — Weather Lookup
This is the baseline pattern. One function, one call, one result.
Step 1: Define the Tool Schema
get_weather = types.FunctionDeclaration(
name="get_weather",
description="Returns current temperature and conditions for a city. "
"Use when the user asks about weather in a specific location.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"city": types.Schema(
type=types.Type.STRING,
description="City name, e.g. 'Tokyo' or 'New York'",
),
"unit": types.Schema(
type=types.Type.STRING,
enum=["celsius", "fahrenheit"],
description="Temperature unit. Default to celsius.",
),
},
required=["city"], # unit is optional — model fills it from context
),
)
tools = types.Tool(function_declarations=[get_weather])
Step 2: Send the Initial Request
response = client.models.generate_content(
model=MODEL,
contents="What's the weather in Tokyo right now?",
config=types.GenerateContentConfig(tools=[tools]),
)
Step 3: Handle the Tool Call and Return the Result
import json
def handle_tool_calls(response, tools_config):
"""Run the tool call loop until the model returns a final text response."""
contents = [
types.Content(role="user", parts=[types.Part(text="What's the weather in Tokyo right now?")])
]
# Append model's function_call turn
contents.append(response.candidates[0].content)
for part in response.candidates[0].content.parts:
if part.function_call:
fc = part.function_call
# Your actual implementation goes here
result = call_real_weather_api(fc.args["city"], fc.args.get("unit", "celsius"))
# Return result as a tool role message — NOT user role
contents.append(
types.Content(
role="tool",
parts=[
types.Part(
function_response=types.FunctionResponse(
name=fc.name,
response={"result": result},
)
)
],
)
)
# Send tool result back to get final answer
final = client.models.generate_content(
model=MODEL,
contents=contents,
config=types.GenerateContentConfig(tools=tools_config),
)
return final.text
def call_real_weather_api(city: str, unit: str) -> dict:
# Replace with real API call (e.g. Open-Meteo, WeatherAPI)
return {"city": city, "temperature": 18, "unit": unit, "conditions": "Partly cloudy"}
Expected output:
The weather in Tokyo is currently 18°C and partly cloudy.
If it fails:
AttributeError: 'NoneType' has no attribute 'function_call'→ Checkfinish_reason. If it'sSTOPnotTOOL_CALLS, the model answered without calling the tool. Strengthen your function description.- Model calls wrong function → Add a
system_instructionthat maps intents to tools explicitly.
Example 2: Parallel Tool Calls — Multi-City Weather
Gemini 2.0 can call multiple tools in a single response. One loop handles all of them.
response = client.models.generate_content(
model=MODEL,
contents="Compare the weather in Tokyo, London, and São Paulo.",
config=types.GenerateContentConfig(tools=[tools]),
)
# Gemini returns multiple function_call parts in one candidate
tool_results = []
for part in response.candidates[0].content.parts:
if part.function_call:
fc = part.function_call
result = call_real_weather_api(fc.args["city"], fc.args.get("unit", "celsius"))
tool_results.append(
types.Part(
function_response=types.FunctionResponse(
name=fc.name,
response={"result": result},
)
)
)
# All results go back in a single tool message
contents = [
types.Content(role="user", parts=[types.Part(text="Compare the weather in Tokyo, London, and São Paulo.")]),
response.candidates[0].content,
types.Content(role="tool", parts=tool_results),
]
final = client.models.generate_content(
model=MODEL,
contents=contents,
config=types.GenerateContentConfig(tools=[tools]),
)
print(final.text)
Expected output:
Tokyo: 18°C, partly cloudy
London: 11°C, overcast
São Paulo: 27°C, sunny
Tokyo is the coolest of the three, while São Paulo is the warmest...
All three results go into a single tool content block. Sending them as separate messages causes a 400 INVALID_ARGUMENT error.
Example 3: Multi-Tool Chaining — Search Then Summarize
Real agents chain tools: look up data with one function, pass it to another.
Define Two Tools
search_docs = types.FunctionDeclaration(
name="search_docs",
description="Searches the internal documentation database. "
"Use when the user asks about a product feature or policy.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"query": types.Schema(type=types.Type.STRING, description="Search query"),
"top_k": types.Schema(type=types.Type.INTEGER, description="Number of results, default 3"),
},
required=["query"],
),
)
create_summary = types.FunctionDeclaration(
name="create_summary",
description="Generates a structured summary from raw text chunks. "
"Use after search_docs returns results.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"chunks": types.Schema(
type=types.Type.ARRAY,
items=types.Schema(type=types.Type.STRING),
description="List of text chunks to summarize",
),
"format": types.Schema(
type=types.Type.STRING,
enum=["bullets", "paragraph"],
),
},
required=["chunks", "format"],
),
)
tools = types.Tool(function_declarations=[search_docs, create_summary])
Run the Chain
def run_agent(user_query: str) -> str:
contents = [types.Content(role="user", parts=[types.Part(text=user_query)])]
# Allow up to 5 tool rounds before forcing a final answer
for _ in range(5):
response = client.models.generate_content(
model=MODEL,
contents=contents,
config=types.GenerateContentConfig(tools=[tools]),
)
candidate = response.candidates[0]
# No more tool calls — return the final text
if candidate.finish_reason.name == "STOP":
return candidate.content.parts[0].text
# Append model's turn and process tool calls
contents.append(candidate.content)
tool_results = []
for part in candidate.content.parts:
if part.function_call:
fc = part.function_call
result = dispatch_tool(fc.name, fc.args)
tool_results.append(
types.Part(
function_response=types.FunctionResponse(
name=fc.name,
response={"result": result},
)
)
)
contents.append(types.Content(role="tool", parts=tool_results))
return "Agent reached max iterations without finishing."
def dispatch_tool(name: str, args: dict):
if name == "search_docs":
return fake_doc_search(args["query"], args.get("top_k", 3))
if name == "create_summary":
return fake_summarizer(args["chunks"], args["format"])
raise ValueError(f"Unknown tool: {name}")
The finish_reason == "STOP" check is what ends the loop. Without it you'll keep sending empty tool results back forever.
Example 4: Schema Validation and Error Handling
The model occasionally passes arguments your function can't handle. Catch this at the tool layer, not the model layer.
from pydantic import BaseModel, ValidationError
class WeatherArgs(BaseModel):
city: str
unit: str = "celsius"
def safe_weather_call(raw_args: dict) -> dict:
try:
args = WeatherArgs(**raw_args)
except ValidationError as e:
# Return the error as the tool result — model will self-correct
return {"error": str(e), "hint": "city must be a non-empty string"}
return call_real_weather_api(args.city, args.unit)
Returning the error as a tool result lets Gemini retry with corrected arguments in the next turn. Raising an exception in your dispatcher breaks the loop and gives the user nothing.
Verification
# Smoke test — confirms the tool loop works end to end
result = run_agent("What's the weather in Berlin?")
assert isinstance(result, str)
assert len(result) > 10
print("✅ Tool loop works:", result)
You should see:
✅ Tool loop works: The weather in Berlin is currently...
Check that finish_reason is STOP on the final response, not MAX_TOKENS or SAFETY.
What You Learned
- Gemini function calling is a request/response loop — you execute the function, send results back as
role: tool - Parallel calls from one response go into a single
toolcontent block, not multiple messages - A
for _ in range(N)loop with afinish_reason == STOPexit handles any depth of chaining - Return errors as tool responses so the model can self-correct instead of crashing
Limitation: gemini-2.0-flash truncates long tool responses. If your function returns > 10k tokens, chunk the result before sending it back or switch to gemini-2.0-pro.
Tested on google-genai 1.7.0, Python 3.12, gemini-2.0-flash and gemini-2.0-pro