Skip to content

[BUG]: output_schema=str combined with tools causes infinite call_llm/execute_tools loop #5054

Closed

Description

@surfai

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 response

Expected Behavior

Either:

  1. output_schema=str works correctly with tools — the LLM calls tools, then uses set_model_response to return a plain string, OR
  2. 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:

  1. LLM generates a function_call (e.g., plan_visualization)
  2. ADK executes the tool and returns the result
  3. LLM generates another function_call instead of calling set_model_response
  4. 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

Environment

  • google-adk 2.0.0a2
  • Model: gemini-2.5-pro (also tested with gemini-2.5-flash)
  • Python 3.14

Context for this issue was developed with AI assistance (Claude).

Activity

  1. added
    core[Component] This issue is related to the core interface and implementation
    on Mar 30, 2026
  2. added theissue type on Mar 30, 2026
  3. nino-robotfutures-co commented on Mar 30, 2026

    @nino-robotfutures-co

    Further testing across models:

    Model output_schema + 7 tools Tool loop? Structured output produced?
    gemini-2.5-flash HalOutput (BaseModel) Yes — loops on check_backend_health ~15x, eventually breaks out Yes, after loop
    gemini-3-flash-preview HalOutput (BaseModel) Yes — 47 model calls, never terminates within 300s No
    gemini-3.1-pro-preview HalOutput (BaseModel) No — 1 model call Yes, first attempt

    gemini-3.1-pro-preview handles output_schema + tools correctly 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_response when other tools are available — the flash models prefer calling tools over producing the structured output.

  4. surajksharma07 commented on Mar 30, 2026

    @surajksharma07
    Collaborator

    @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!

  5. surfai commented on Mar 31, 2026

    @surfai
    Author

    Thanks @nino-robotfutures-co for the model comparison table — that data was key to understanding this. And @surajksharma07 for tracing it to _OutputSchemaRequestProcessor and offering a direction.

    We ran into this issue building a multi-agent Workflow (ADK 2.0.0a2) and worked around it by removing output_schema from any agent that also has tools. 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: _OutputSchemaRequestProcessor always adds the set_model_response tool + instruction, and execute_tools always routes back to call_llm when the LLM produces function calls. Whether the loop terminates depends entirely on whether the LLM chooses to call set_model_response vs. 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 for str, BaseModel, and all other schema types — no branching by type. It's also hardcoded inside ADK's request processor pipeline (_create_workflow_request_processors in _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_response tool signature the LLM sees:

    • BaseModel → set_model_response(*, summary: str, score: int) — distinct named fields
    • str → set_model_response(*, response: str) — single generic field

    Flash 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:

    # _output_schema_processor.py:52-64 — proposed type-aware instruction
    from ..utils._schema_utils import is_basemodel_schema
    
    if is_basemodel_schema(agent.output_schema):
        instruction = (
            'After completing any needed tool calls, provide your final '
            'response by calling set_model_response with the required fields.'
        )
    else:
        instruction = (
            'CRITICAL: You MUST call the set_model_response tool exactly once '
            'as your FINAL action. Do NOT respond with plain text. Do NOT call '
            'any other tools after set_model_response. This is the ONLY way '
            'to complete the task.'
        )

    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_choice on round N-1 — constrain the LLM to only set_model_response, guaranteeing a valid structured response:

    # _execute_tools_node.py — at the end of execute_tools()
    _MAX_TOOL_ROUNDS = 25
    
    round_key = 'temp:tool_round_count'
    rounds = ctx.state.get(round_key, 0) + 1
    ctx.state[round_key] = rounds
    
    if rounds >= _MAX_TOOL_ROUNDS - 1:
        # Next call_llm round: force the model to call set_model_response
        ctx.state['temp:force_set_model_response'] = True
    yield _continue_event()
    # _output_schema_processor.py — read the flag during the next call_llm cycle
    if ctx.state.get('temp:force_set_model_response'):
        llm_request.config.tool_config = types.ToolConfig(
            function_calling_config=types.FunctionCallingConfig(
                mode='ANY',
                allowed_function_names=['set_model_response'],
            )
        )

    Layer 2: Hard cutoff on round N — last-resort termination if the forced tool_choice still doesn't produce output:

    # _execute_tools_node.py — above the tool_choice flag check
    if rounds >= _MAX_TOOL_ROUNDS:
        logger.error(
            'Tool execution reached %d rounds without termination, '
            'breaking loop to prevent runaway API costs.',
            _MAX_TOOL_ROUNDS,
        )
        return  # End workflow — don't route back to call_llm

    The stricter instruction (point 2) reduces how often the guard is needed; the tool_choice enforcement 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 return after successful set_model_response in _execute_tools_node.py.

    Separate from the infinite loop — when set_model_response IS successfully called, the final_event is yielded (line 364) but there's no return. Execution falls through to yield _continue_event() (line 389), causing one unnecessary extra call_llm round trip even on success:

    # _execute_tools_node.py:360-364 — proposed fix
    if json_response:
        final_event = _output_schema_processor.create_final_model_response_event(
            invocation_context, json_response
        )
        yield final_event.model_copy()
        return  # Structured output produced — terminate loop
  6. surfai commented on Mar 31, 2026

    @surfai
    Author

    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_choice is 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_choice guard we suggested in point 3 above is essentially a lightweight version of Pattern B, applied as a safety net rather than the primary mechanism.

  7. surajksharma07 commented on Mar 31, 2026

    @surajksharma07
    Collaborator

    @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.

  8. Vasanthan88 commented on Apr 11, 2026

    @Vasanthan88

    @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

  9. surajksharma07 commented on Apr 12, 2026

    @surajksharma07
    Collaborator

    @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!

  10. vietnamesekid commented on Apr 13, 2026

    @vietnamesekid
    Contributor

    @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?

  11. Vasanthan88 commented on Apr 13, 2026

    @Vasanthan88

    @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

  12. 6 remaining items

  13. vietnamesekid commented on Apr 14, 2026

    @vietnamesekid
    Contributor

    Hey @surajksharma07 , all four points are already in there

    1. Type aware instruction BaseModel gets the softer prompt, primitives get the "IMPORTANT: you MUST call" one
    2. Force tool_choice on round N-1 mode=ANY + allowed_function_names=['set_model_response']
    3. Hard cutoff on round N end_invocation = True, stops cold
    4. Early return after set_model_response both spots in base_llm_flow.py

    Happy to walk through any of them if something looks off

    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 in base_llm_flow.py return is added at both spots after set_model_response is detected, so execution doesn't fall through to the next call_llm round.

    Same behavior, different file

  14. surajksharma07 commented on Apr 15, 2026

    @surajksharma07
    Collaborator

    @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.

  15. Vasanthan88 commented on Apr 15, 2026

    @Vasanthan88

    @surajksharma07 - I checked in 1.29 still dedupe_tool_calls is not available in RunConfig.

    Getting validation error for RunConfig

  16. surajksharma07 commented on Apr 19, 2026

    @surajksharma07
    Collaborator

    @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.

  17. vietnamesekid commented on May 15, 2026

    @vietnamesekid
    Contributor

    Any updates on this, @surajksharma07 ?

  18. Fame510 commented on Jul 4, 2026

    @Fame510

    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 the call_llm → execute_tools cycle 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.

  19. added
    spam[Status] Issues suspected of having comments which are spam
    on Jul 5, 2026
  20. adk-bot commented on Jul 5, 2026

    @adk-bot
    Collaborator

    🚨 Automated Spam Detection Alert 🚨
    @maintainers, a suspected spam comment was detected in this thread.

    Reason:

    @Fame510 posted a promotional comment advertising their own 3rd-party repository (https://github.com/Fame510/SHACKLE).
    
  21. surajksharma07 commented on Aug 28, 2026

    @surajksharma07
    Collaborator

    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.

  22. yrangana commented on Aug 30, 2026

    @yrangana

    Hit the same loop on a path this thread hasn't covered: models wrapped in LiteLlm.

    LiteLlm.capabilities returns output_schema_and_tools=True unconditionally
    (models/lite_llm.py:3075), regardless of the wrapped model. ADK then sets response_schema
    alongside the tool declarations and SetModelResponseTool never engages, so the loop happens
    for the same reason described here.

    Two notes on how this relates to the existing work:

    Consistent with the model comparison table above: gemini-3.1-flash-lite via OpenRouter loops
    4, 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:

    litellm.supports_response_schema(model="openrouter/google/gemini-3.1-flash-lite")   # True
    litellm.supports_function_calling(model="openrouter/google/gemini-3.1-flash-lite")  # True

    Filed as #6954 with a reproduction, per the request on #5057 to open a new issue. Happy to
    send a PR.

  23. surajksharma07 commented on Sep 8, 2026

    @surajksharma07
    Collaborator

    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.

  24. removed
    spam[Status] Issues suspected of having comments which are spam
    on Oct 14, 2026
  25. DeanChensj commented on Oct 14, 2026

    @DeanChensj
    Collaborator

    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.

  26. github-actions commented on Oct 14, 2026

    @github-actions

    Are you satisfied with the resolution of your issue?
    Yes
    No

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

core[Component] This issue is related to the core interface and implementationneeds review[Status] The PR/issue is awaiting review from the maintainer

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions