Repository navigation
[BUG]: output_schema=str combined with tools causes infinite call_llm/execute_tools loop #5054
Description
Description
Using bare str type as output_schema on an Agent that also has tools causes an infinite call_llm → execute_tools → call_llm loop. The LLM repeatedly calls tools instead of producing the final structured response via set_model_response.
This does not happen with BaseModel subclasses as output_schema, only with primitive types like str.
Reproduction
from google.adk import Agent, Workflow, Event
def plan_visualization(approach: str, technology: str, key_elements: list[str]) -> str:
"""Plan a visualization."""
return f"Plan: {approach} using {technology}"
def query_data(query: str) -> list[dict]:
"""Query the database."""
return [{"q": "Q1", "revenue": 100}]
# This agent loops infinitely:
plan_agent = Agent(
name="plan_agent",
model="gemini-2.5-pro",
instruction="Call plan_visualization with approach, technology, and key elements.",
tools=[plan_visualization, query_data],
output_schema=str, # <-- bare str causes the loop
mode="single_turn",
)
# Use in a Workflow or with Runner — agent calls tools repeatedly
# without ever settling on a final string responseExpected Behavior
Either:
output_schema=strworks correctly with tools — the LLM calls tools, then usesset_model_responseto return a plain string, OR- ADK raises a validation error at agent creation time:
"output_schema must be a BaseModel subclass when tools are specified"
Actual Behavior
The agent enters an infinite loop:
- LLM generates a
function_call(e.g.,plan_visualization) - ADK executes the tool and returns the result
- LLM generates another
function_callinstead of callingset_model_response - Repeat endlessly — burns API credits silently
Analysis
ADK auto-adds set_model_response when both output_schema and tools are specified (documented here). With BaseModel schemas, the model correctly calls set_model_response with structured JSON. With bare str, the set_model_response schema may be malformed or confusing to the model, causing it to fall back to repeated tool calls.
Workaround
Remove output_schema from agents that have tools. Use other mechanisms to suppress unwanted text output if needed.
Related Issues
- Agent with output_schema + tools enters infinite loop ADK v1.18.0 #3413 — Agent with output_schema + tools enters infinite loop (model regression, fixed)
- Output Schema consistently ignored by LLM Agent with tools #3969 — Output Schema consistently ignored by LLM Agent with tools
- Output_Schema + Tool with structured arg [List of object] causes blank tool input [{}] and infinite loop #4868 — output_schema + Tool with structured arg causes infinite loop
Environment
google-adk2.0.0a2- Model:
gemini-2.5-pro(also tested withgemini-2.5-flash) - Python 3.14
Context for this issue was developed with AI assistance (Claude).
Activity
nino-robotfutures-co commentedon Mar 30, 2026
Further testing across models:
check_backend_health~15x, eventually breaks outgemini-3.1-pro-preview handles
output_schema+toolscorrectly on first call. The flash models (both 2.5 and 3.0) are the ones susceptible to the infinite loop.This suggests the issue is specific to the flash model family's handling of
set_model_responsewhen other tools are available — the flash models prefer calling tools over producing the structured output.surajksharma07 commentedon Mar 30, 2026
@surfai Traced this down to _OutputSchemaRequestProcessor — the instruction injected for primitive output_schema types isn't explicit enough, so the model keeps looping back to other tools instead of calling set_model_response to terminate.
That's what's driving the cycle.
Swapping in a stricter instruction for non-BaseModel schemas seems to do the trick on my end — something along the lines of "CRITICAL: call set_model_response exactly once as your FINAL action, do not call any other tools after".
Could you try that workaround and see if it holds up in your setup too?
If it does, it'd be worth running it through your test cases before we push a fix!
surfai commentedon Mar 31, 2026
Thanks @nino-robotfutures-co for the model comparison table — that data was key to understanding this. And @surajksharma07 for tracing it to
_OutputSchemaRequestProcessorand offering a direction.We ran into this issue building a multi-agent Workflow (ADK 2.0.0a2) and worked around it by removing
output_schemafrom any agent that also hastools. Went back and traced the code path to understand the mechanism — a few findings that might help:1. The issue is non-deterministic — it's LLM behavior, not a code path bug.
The ADK code is fully deterministic:
_OutputSchemaRequestProcessoralways adds theset_model_responsetool + instruction, andexecute_toolsalways routes back tocall_llmwhen the LLM produces function calls. Whether the loop terminates depends entirely on whether the LLM chooses to callset_model_responsevs. other tools. @nino-robotfutures-co's table confirms this — same code, different models, different outcomes.2. The instruction is identical for all schema types — and it's an ADK internal, not user-facing.
@surajksharma07 mentions the instruction for primitive types isn't explicit enough. Looking at
_output_schema_processor.py:57-64, the instruction text is actually the same forstr,BaseModel, and all other schema types — no branching by type. It's also hardcoded inside ADK's request processor pipeline (_create_workflow_request_processorsin_call_llm_node.py), which is a fixed list of internal processors with no public extension point. Users can't customize or override this instruction without monkey-patching.What does differ by type is the
set_model_responsetool signature the LLM sees:BaseModel→set_model_response(*, summary: str, score: int)— distinct named fieldsstr→set_model_response(*, response: str)— single generic fieldFlash models may treat the trivial single-field signature as redundant ("I can just respond with text") rather than recognizing it as a required termination mechanism. The named fields in the BaseModel case likely act as a stronger signal.
A type-aware instruction could help — differentiating the emphasis by schema complexity:
3. A stricter instruction improves probability but can't guarantee prevention.
Since the root cause is LLM tool-selection behavior (not a code bug), a prompt change is a probabilistic improvement — it may help flash models comply more often, but there's no instruction that can guarantee a model will always call a specific tool. We can't meaningfully validate this on our end either: passing 5 or 10 times doesn't prove it won't fail on attempt 11, especially since behavior varies by model, temperature, and context length.
A deterministic safety net would complement the instruction fix — two layers:
Layer 1: Force
tool_choiceon round N-1 — constrain the LLM to onlyset_model_response, guaranteeing a valid structured response:Layer 2: Hard cutoff on round N — last-resort termination if the forced
tool_choicestill doesn't produce output:The stricter instruction (point 2) reduces how often the guard is needed; the
tool_choiceenforcement ensures valid output when the model won't comply; the hard cutoff prevents runaway costs in all cases. All three together would be robust.4. Secondary: missing
returnafter successfulset_model_responsein_execute_tools_node.py.Separate from the infinite loop — when
set_model_responseIS successfully called, thefinal_eventis yielded (line 364) but there's noreturn. Execution falls through toyield _continue_event()(line 389), causing one unnecessary extracall_llmround trip even on success:surfai commentedon Mar 31, 2026
Follow-up: two architectural patterns that avoid this problem
While investigating we came across two patterns that reframe this from a non-deterministic instruction-following problem into a deterministic routing problem:
Pattern A: Run tools first, then ask for structured output separately.
The agent runs in two phases. In phase 1, the model has access to all its tools and calls them freely — no output schema is involved. The loop continues until the model decides it has enough information and stops making tool calls. Then, in phase 2, the model is re-invoked in a constrained mode: it receives the full conversation history but is bound to produce only the structured output, with no tools available at all. The model can't get confused between "should I call a tool or produce the structured response?" because those two actions never compete in the same LLM call.
Pattern B: Make the schema a tool, but force the model to always pick a tool.
The output schema is registered as an additional tool alongside the real tools (similar to ADK's
set_model_response). The key difference:tool_choiceis set to"any", which means the model must call at least one tool on every turn — it cannot just produce plain text and move on. On each turn, the model either calls a real tool (to keep working) or calls the schema tool (to finish). When the routing logic sees the schema tool was called, it exits the loop. The model can't silently ignore the schema tool because it's forced to pick something from the tool list every turn.Both patterns make the termination condition deterministic — it doesn't depend on how well a particular model follows a prompt instruction. The
tool_choiceguard we suggested in point 3 above is essentially a lightweight version of Pattern B, applied as a safety net rather than the primary mechanism.surajksharma07 commentedon Mar 31, 2026
@surfai Good catch on the instruction text — you're right, I was wrong about that.
It's actually the same string across all schema types, no branching at all.
The real differentiator is exactly what you pointed out: the tool signature itself. Distinct named fields for BaseModel vs. a single generic response: str that flash models tend to treat as optional — that's where the behavior diverges.
The 2-layer approach makes a lot of sense here: type-aware instruction + tool_choice enforcement as a safety net + hard cutoff.
That's what makes this deterministic rather than just prompt-probabilistic, which is the right goal.
The missing return after set_model_response success is a solid find too — definitely worth bundling into the same fix rather than leaving it loose.
Could you drop these findings over on #5057?
That PR is already targeting this area, so having the full picture there would really help the reviewer.
fix: harden SetModelResponseTool fallback to prevent infinite loops
fix: harden SetModelResponseTool fallback to prevent infinite loops
fix: harden SetModelResponseTool fallback to prevent infinite loops
Vasanthan88 commentedon Apr 11, 2026
@surajksharma07 : Can you provide full sample code with two lays implementation with output schema and tool call.
I have created a bug #3940 for the same issue and had a different work around.
I want to try both solution so can you provide a full same code for above work around.
Thanks,
Vasanth
surajksharma07 commentedon Apr 12, 2026
@Vasanthan88 — worth separating these two since they have different root causes!
The #3940 issue is a slow-tool timing problem where the LLM retries before the tool response arrives.
The fix there is RunConfig(dedupe_tool_calls=True) from PR #4189 — worth checking if that's already landed on your current ADK version since the commit went in back in February.
The #5054 loop is a different story — output_schema=str produces a weak set_model_response signature that flash models tend to treat as optional, which is what drives the infinite loop.
The full 2-layer fix (type-aware instruction + tool_choice enforcement + hard cutoff) is laid out in surfai's comments above with code sketches, and PRs #5057 and #5091 are where that's being implemented.
Try dedupe_tool_calls first for the #3940 side and let us know if that's still open on your end!
vietnamesekid commentedon Apr 13, 2026
@surajksharma07 since I opened PRs #5057 and #5091, just checking is there anything you’d like me to update or adjust to help move them forward?
Vasanthan88 commentedon Apr 13, 2026
@surajksharma07 : still dedupe_tool_call is not released . Checked in version 1.28.1 Google adk version.
Can you prove the same code for layer 1 and layer 2 now so that I can try it. I am also facing this issue after introducing output_schema and tool call
Thanks,
Vasanth
6 remaining items
vietnamesekid commentedon Apr 14, 2026
One clarification on point 4 the fix isn't in
_execute_tools_node.py(that's the sketch architecture from surfai's comment). The actual ADK codebase doesn't have that file. The equivalent fix lives inbase_llm_flow.pyreturnis added at both spots afterset_model_responseis detected, so execution doesn't fall through to the nextcall_llmround.Same behavior, different file
surajksharma07 commentedon Apr 15, 2026
@vietnamesekid Perfect — all four confirmed, and the base_llm_flow.py clarification is helpful.
The fix lands where it needs to regardless of surfai's sketch filename, so that's not a concern.
That's exactly the right shape: type-aware instruction for the probabilistic layer, tool_choice + hard cutoff for the deterministic safety net, and the early return to clean up the success path.
Should be solid for @Jacksunwei's review — thanks for the thorough rundown!
Team is looking more into it and it is under review.
Vasanthan88 commentedon Apr 15, 2026
@surajksharma07 - I checked in 1.29 still dedupe_tool_calls is not available in RunConfig.
Getting validation error for RunConfig
surajksharma07 commentedon Apr 19, 2026
@Vasanthan88 PR #4189 hasn't been merged into any release yet, which is exactly why you're hitting the ValidationError — RunConfig uses extra='forbid' so any unknown field fails Pydantic validation immediately.
For the #3940 slow-tool loop right now, two things that actually work with the current API: use RunConfig(max_llm_calls=5) to hard-cap total LLM rounds per run (prevents infinite burn even if the lock misses), and keep the session-based lock workaround from December as the dedup layer.
That combination is the most defensive setup available until #4189 lands.
Team is looking more into it.
vietnamesekid commentedon May 15, 2026
Any updates on this, @surajksharma07 ?
Fame510 commentedon Jul 4, 2026
Separate from the root-cause fix (validation on
output_schema=str+ tools) — for defense in depth against this class, a runtime iteration/duplicate-call breaker in front of thecall_llm → execute_toolscycle stops it becoming an unbounded spend loop while the schema bug is being fixed. I maintain a small framework-agnostic one (decide()with max-iteration + duplicate-call caps): https://github.com/Fame510/SHACKLE. Not a substitute for the fix, just a backstop so a repro like this can't run away.adk-bot commentedon Jul 5, 2026
🚨 Automated Spam Detection Alert 🚨
@maintainers, a suspected spam comment was detected in this thread.
Reason:
surajksharma07 commentedon Aug 28, 2026
Sorry for the silence here. #5057 and #5091 are both still open and this isn't fixed in any release yet, confirmed as of v2.8.0. @wuliang229's Vertex-repro question on #5057 and @vietnamesekid's ping on #5091 both went unanswered from our side, chasing review on both now. Keeping this open until one lands.
Team is looking more into it.
yrangana commentedon Aug 30, 2026
Hit the same loop on a path this thread hasn't covered: models wrapped in
LiteLlm.LiteLlm.capabilitiesreturnsoutput_schema_and_tools=Trueunconditionally(
models/lite_llm.py:3075), regardless of the wrapped model. ADK then setsresponse_schemaalongside the tool declarations and
SetModelResponseToolnever engages, so the loop happensfor the same reason described here.
Two notes on how this relates to the existing work:
LiteLlmbypasses that gate, soit wouldn't have helped anyone going through LiteLLM.
SetModelResponseTool, but that only applies once the fallback is active. Onthis path it never activates.
Consistent with the model comparison table above:
gemini-3.1-flash-litevia OpenRouter loops4, 12, 18 and 19 times across four runs of identical code, and 13 generations cost 26x more
than the same question without tools (input tokens ~300x, since each retry resends the whole
conversation).
Deriving the capability from LiteLLM's metadata doesn't work either. It reports both
independently and has no API for the combination:
Filed as #6954 with a reproduction, per the request on #5057 to open a new issue. Happy to
send a PR.
surajksharma07 commentedon Sep 8, 2026
Thanks for tracing this to the LiteLlm angle. Quick status: #5057 ended up closed as stale, the version-bucket code it patched (is_gemini_2_or_above) is gone now replaced by models/_capabilities.gemini_output_schema_and_tools so that route needs a fresh PR with a Vertex repro. #5091 is still the right hardening fix for the native path and just needs a reviewer to pick it back up. Good catch filing #6954 separately, #6955 fixes that LiteLlm.capabilities gate and you've already verified it drops tool calls from 4-19 down to a steady 2 so that one should land soon. Keeping #5054 open until #5091 or a #5057 replacement merges.
DeanChensj commentedon Oct 14, 2026
Hi @surfai, thank you for reporting this issue!
This has been resolved by PR #5091 and merged into the main branch in commit d185ca5.
We are closing this issue as resolved. If you find that the issue persists on the latest ADK release or if this was closed in error, please feel free to reopen it or leave a comment! Thank you again for your contribution.
github-actions commentedon Oct 14, 2026
Are you satisfied with the resolution of your issue?
Yes
No