●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Stopping Gemini API Function Calling Loops: Why They Happen and How to Break Them
Your tool-calling agent keeps invoking the same function and never finishes. Here is how to diagnose the loop and bake stop conditions into your prompt, code, and tool responses — including catching regressions when the default model changes and detecting result-based stalls.
You build a small agent, hit Run, and watch search_web get called over and over while your API bill ticks up in the corner. If you have ever shipped a Gemini Function Calling flow, you probably know that quiet moment of panic. I hit it the very first time I wired a search tool to Gemini — the model insisted there was just "one more query" to run, every single turn.
Most Gemini Function Calling loops are not bugs in the library. They come from a mismatch between the model, the tool, and the prompt — the conversation itself forgot how to end. This article walks through why these loops happen, shows the diagnostic code I actually reach for, and lays out a three-layer stop-condition design that has held up in production.
Loops happen because the model never learned how to stop
When Function Calling loops, the model is not being stubborn. It is making a reasonable decision given the signals it sees: the tool returned something, the goal has not obviously been met, and calling the tool again seems like the safest next move. That decision repeats because one of the following is true.
The tool's return value looks "incomplete" to the model
The system prompt never describes what "done" means
Your code forces another tool turn even when the model is trying to respond in text
There is no guardrail detecting the same call repeating with the same arguments
Put differently, the model is doing its best with the information it has, and nobody has told it when to stop. That framing makes debugging a lot faster.
Start by watching finish_reason and the call history
Before you change anything, make the conversation visible. The minimum viable probe in the google-genai Python SDK looks like this.
# Goal: print every tool call so we can see where the loop startsfrom google import genaifrom google.genai import typesclient = genai.Client()def search_web(query: str) -> dict: # Placeholder — wire this to your real search API return {"results": [{"title": f"dummy for {query}"}]}tools = [types.Tool(function_declarations=[ types.FunctionDeclaration( name="search_web", description="Search the web for a keyword", parameters={"type": "object", "properties": { "query": {"type": "string"}}, "required": ["query"]}, )])]history = [types.Content(role="user", parts=[ types.Part.from_text(text="Summarize the latest Gemini 2.5 Pro updates")])]for turn in range(6): # hard stop after 6 turns while investigating resp = client.models.generate_content( model="gemini-2.5-pro", contents=history, config=types.GenerateContentConfig(tools=tools), ) cand = resp.candidates[0] print(f"[turn {turn}] finish_reason={cand.finish_reason}") call = next((p.function_call for p in cand.content.parts if p.function_call), None) if not call: print("final text:", resp.text) break print(f" tool={call.name} args={dict(call.args)}") result = search_web(**call.args) if call.name == "search_web" else {} history.append(cand.content) history.append(types.Content(role="tool", parts=[ types.Part.from_function_response(name=call.name, response=result)]))
Run this and you will see the tool and arguments for each turn. If you are in a loop, you will almost certainly see the same tool name with the same arguments repeat. That single observation tells you where the stop condition needs to live.
Three signals worth watching
The same tool with the same arguments called more than twice → prompt or tool response does not communicate "enough"
finish_reason is MAX_TOKENS instead of STOP → you ran out of output budget, not ideas
Tool responses return the same payload → your tool is caching something that is no longer useful
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A two-layer stop condition: exact-match duplicate detection plus result-fingerprint stall detection for loops where args differ but progress does not
✦A canary that catches loop regressions via average tool-call count even when the default model silently swaps to Gemini 3.5 Flash (GA)
✦Stop conditions baked across prompt, code, and tool return values, returning the best available summary at the cap instead of going silent
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Once you can see the loop, plug it at three layers instead of one. Patching only the prompt or only the code tends to leak again a week later.
1. Prompt layer. Add the termination rule to your System Instructions. Something like "Respond in markdown without calling a tool once you have enough information. Never call the same tool more than twice." Specify both the behavior and the numeric cap.
2. Code layer. Always impose an iteration ceiling. Six to ten turns is plenty for most agents. When you hit it, do not raise — ask the model politely for a final answer given what it has so far.
3. Tool response layer. Teach the return payload to describe its own completeness. The model reads this payload like a human reads a status bar, and a single status field changes its next move dramatically.
An implementation that combines all three
# Goal: cap iterations, detect repeated calls, and nudge the model to wrap upMAX_TURNS = 8def key_of(call) -> str: # a call is "the same call" if tool name + sorted args match return f"{call.name}:{sorted(dict(call.args).items())}"seen = {}for turn in range(MAX_TURNS): resp = client.models.generate_content( model="gemini-2.5-pro", contents=history, config=types.GenerateContentConfig(tools=tools)) cand = resp.candidates[0] call = next((p.function_call for p in cand.content.parts if p.function_call), None) if call is None: print("final:", resp.text) break k = key_of(call) seen[k] = seen.get(k, 0) + 1 if seen[k] >= 3: # the same call has repeated — redirect the model to wrap up history.append(cand.content) history.append(types.Content(role="user", parts=[types.Part.from_text( text=f"The tool {call.name} has been called with the same " f"arguments multiple times. Please write the best possible " f"answer using what we have so far.")])) continue result = search_web(**call.args) history.append(cand.content) history.append(types.Content(role="tool", parts=[ types.Part.from_function_response(name=call.name, response=result)]))else: print("iteration cap reached; generate a partial response")
The key move is what happens when the cap fires. Instead of raising an exception, we append a user-role message that tells the model to wrap up with whatever it has. Gemini is very cooperative with this kind of redirect, so your users see a real answer instead of silence.
Tool return values are where the real fix lives
The single biggest reduction in looping I have shipped came from rewriting tool responses so the model can read their state at a glance. A search tool, for example, returns this shape.
Pair that with a System Instructions line such as "Stop calling tools and write the final answer when status is sufficient" and the average number of tool calls drops fast. On one of my projects it went from 5.2 calls per session to 1.8 after this change alone.
When the default model silently changes, loops come back
A loop you stabilized with everything above can start spinning again one day — without your touching the code or the prompt. One common cause is a generational change in the default model. In June 2026, Gemini 3.5 Flash became generally available and, in some environments, was switched on as the default. If you specify the model only by generation, or omit it and let the SDK decide, the model's tool-calling eagerness and its sense of "when to stop" can quietly swap out for another model's.
I run article-update automation as an indie developer, and I have watched a summarizer's default model swap underneath me and push the average tool-call count up. The stop conditions themselves were still correct, but when the model changes, so does its instinct for "one more call should do it" — and the frequency of pinning to the cap rises. So I now pin the loop's model explicitly and record the response's model_version every run, checking that "the model I asked for" and "the model that answered" still match.
# Goal: pin the loop's model and detect a requested-vs-served swapREQUESTED_MODEL = "gemini-3.5-flash" # pin explicitly, not just by generationresp = client.models.generate_content( model=REQUESTED_MODEL, contents=history, config=types.GenerateContentConfig(tools=tools))served = getattr(resp, "model_version", None)if served and not served.startswith(REQUESTED_MODEL): # default-model swap or alias resolution may have routed you to another generation import logging logging.warning("model drift: requested=%s served=%s", REQUESTED_MODEL, served)
It also pays to watch whether your stop conditions are still working, with a single number. I aggregate how many tool calls each conversation made, grouped by model_version, and if the average climbs noticeably above normal I suspect a model generation change or prompt degradation. Drop this small canary into your production logs and you catch a regression at "the average moved," not at "the bill ballooned."
# Goal: aggregate average tool-call count per model_version to catch regressions earlyfrom collections import defaultdictdef tool_call_canary(rows: list[dict]) -> dict: # rows: [{"model_version": str, "tool_calls": int}, ...] for the day agg = defaultdict(list) for r in rows: agg[r["model_version"]].append(r["tool_calls"]) report = {} for model, calls in agg.items(): avg = sum(calls) / len(calls) report[model] = { "runs": len(calls), "avg_tool_calls": round(avg, 2), "hit_cap_ratio": round(sum(c >= 8 for c in calls) / len(calls), 2), # MAX_TURNS=8 } return report
If hit_cap_ratio — the share of runs that pinned to the cap — jumps to several times its usual value, that is a sign your stop condition broke. I keep this in a nightly log rollup, and the week a default model changed under me, this is exactly the number that reacted. Don't stop at adding stop conditions; keep observing whether they still bite. For automation that runs for the long haul, that habit is what pays off.
Catching the "different args, no progress" loop
The duplicate detection earlier counted exact matches of tool name plus arguments. The nastier production case, though, is a model that nudges its arguments slightly each turn while effectively circling the same spot. query="Gemini latest", then query="Gemini latest update", then query="Gemini latest news" — the arguments differ every time, so exact matching never catches it, yet the results come back almost identical. The conversation advances while the information does not move an inch.
The fix is to watch whether the tool return values are progressing, not the arguments. Normalize and compare the last few results, and once no new information appears, decide that more calls are wasted and cut the loop.
# Goal: catch loops where args differ but results stop progressing, via result changeimport hashlib, jsondef result_fingerprint(result: dict) -> str: # strip ordering and noise; fingerprint only the "substance" of the result norm = json.dumps(result.get("results", result), sort_keys=True, ensure_ascii=False) return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16]recent = [] # recent result fingerprintsSTALL_WINDOW = 3 # treat as stalled if identical this many times in a row# inside the loop, right after running the tool:fp = result_fingerprint(result)recent.append(fp)recent = recent[-STALL_WINDOW:]if len(recent) == STALL_WINDOW and len(set(recent)) == 1: # args differ but results unchanged three times running -> no new information history.append(cand.content) history.append(types.Content(role="user", parts=[types.Part.from_text( text="Changing the search is not yielding new information. " "Please conclude using only the results you already have.")])) recent.clear() continue
Exact-match duplicate detection and this result-based stall detection each leak on their own. The former catches "the same arguments fired repeatedly"; the latter catches "slightly different arguments circling the same place." They complement each other, and with both in place, even if a model generation change alters the loop's habits, one of the two nets will stop it. The lesson from running agents for a long time as an indie developer is simple: one kind of stop condition is never enough.
Monitor the opposite failure too
The sibling problem to infinite loops is "Function Calling never fires at all." Same prompt, same model, and the tool is ignored or called only once. The diagnostic steps overlap, so it is worth reading both sides together — I cover the non-firing case in what to check when Gemini Function Calling is not triggered. Having both playbooks ready makes you a lot calmer when production behavior shifts unexpectedly.
What to do next
If you only have time for one change today, add three things to your current Function Calling loop: a hard iteration cap, detection of repeated identical calls, and a redirecting user turn when either fires. A perfect stop condition can come later — what you want first is the confidence that the loop always ends. Gemini is a cooperative model once it gets the hint; treat the conversation itself as the place where you teach it how to finish.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.