Skip to content

fix: AgentTool with input_schema + LiteLLM/Claude skips inner agent tool calls #5926

Closed

Description

@ecanlar

Bug description

When an AgentTool is constructed with input_schema=<PydanticModel> and the inner agent uses a Claude model via LiteLLM (LiteLlm("anthropic/claude-*")), the inner agent responds directly without entering the ReAct tool-calling loop. Removing input_schema restores the expected behaviour.

Reproduction

from pydantic import BaseModel
from google.adk.agents import LlmAgent
from google.adk.tools import FunctionTool
from google.adk.tools.agent_tool import AgentTool
from google.adk.models.lite_llm import LiteLlm

class MyInput(BaseModel):
    repository_url: str
    base_branch: str

def my_tool(path: str) -> str:
    """A tool the inner agent should call."""
    return f"content of {path}"

inner_agent = LlmAgent(
    name="inner",
    model=LiteLlm("anthropic/claude-sonnet-4-6"),
    tools=[FunctionTool(func=my_tool)],
    instruction="Always call my_tool with the repository_url before answering.",
)

# With input_schema: inner agent responds directly, never calls my_tool
tool_with_schema = AgentTool(agent=inner_agent, input_schema=MyInput)

# Without input_schema: inner agent calls my_tool as expected
tool_without_schema = AgentTool(agent=inner_agent)

Expected: inner agent calls my_tool before producing a response.
Actual: inner agent responds with text immediately, never invoking my_tool.

Root cause analysis

Two concurring causes identified in ADK 1.28.0:

Cause 1 — agent_tool.py (lines ~205–214)

When input_schema is present, run_async sends the inner agent a raw serialized JSON blob as its first message:

# Current code
text=input_value.model_dump_json(exclude_none=True)
# e.g. '{"repository_url": "https://...", "base_branch": "main"}'

Claude interprets a structured JSON as a completed specification and produces a direct answer rather than initiating tool exploration. Without input_schema, the message is args['request']—natural language—which correctly triggers the ReAct loop.

Proposed fix for agent_tool.py:

if input_schema:
    input_value = input_schema.model_validate(args)
    json_payload = input_value.model_dump_json(exclude_none=True)
    content = types.Content(
        role='user',
        parts=[types.Part.from_text(
            text=(
                'Process the following structured request. Use your available '
                'tools as needed to gather information or perform actions '
                'before producing the final response.\n\n'
                f'Request:\n{json_payload}'
            )
        )],
    )

Cause 2 — lite_llm.py (addresses #773)

_get_completion_inputs and generate_content_async never extract tool_config.function_calling_config.mode from LlmRequest nor pass tool_choice to LiteLLM's acompletion(). Without tool_choice="required", Claude is not forced to call tools even when the caller intends it.

Proposed fix for lite_llm.py (_get_completion_inputs, before return):

tool_choice = None
if (
    llm_request.config
    and llm_request.config.tool_config
    and llm_request.config.tool_config.function_calling_config
):
    from google.genai import types as genai_types
    mode = llm_request.config.tool_config.function_calling_config.mode
    if mode == genai_types.FunctionCallingConfigMode.ANY:
        tool_choice = 'required'
    elif mode == genai_types.FunctionCallingConfigMode.NONE:
        tool_choice = 'none'
    # AUTO → leave as None (provider default)

And in generate_content_async (around line 2230):

if tool_choice is not None:
    completion_args['tool_choice'] = tool_choice

Note: anthropic_llm.py hardcodes tool_choice="auto" (line ~489) — same propagation logic should be applied there for consistency.

Environment

  • ADK version: 1.28.0
  • Model: claude-sonnet-4-6 via LiteLLM (LiteLlm("anthropic/claude-sonnet-4-6"))
  • Python: 3.12

Related issues

Notes

The combination AgentTool + input_schema + native Gemini API may work correctly because Gemini's training makes it more likely to call tools regardless of message format. The bug manifests specifically with Claude-family models via LiteLLM, where the raw JSON prompt is a reliable trigger for skipping the ReAct loop.

Activity

  1. added
    models[Component] This issue is related to model support
    on Jun 1, 2026
  2. added theissue type on Jun 1, 2026
  3. added a commit that references this issue on Jun 1, 2026
    6168c49
  4. surajksharma07 commented on Jun 1, 2026

    @surajksharma07
    Collaborator

    @ecanlar the raw JSON path in agent_tool.py does look like the primary trigger here, and the natural-language wrapper you've proposed should keep Claude in ReAct mode regardless of message format.

    Could you verify on your end that wrapping the serialized payload (i.e., prefixing with "Process the following structured request…\n\nRequest:\n{json}") resolves the tool-skipping behaviour before we move forward with the PR?

    Please make sure to test a few edge cases too — especially with output_schema set alongside input_schema — and confirm the existing unit tests still pass cleanly.

    Once you've validated the workaround end-to-end, we can proceed with the review on #5924!

  5. added
    request clarification[Status] The maintainer need clarification or more information from the author
    on Jun 1, 2026
  6. ecanlar commented on Jun 2, 2026

    @ecanlar
    ContributorAuthor

    The wrapper works as expected when only input_schema is set — Claude stays in ReAct mode correctly.

    However, when output_schema is set alongside input_schema, ADK forces single-shot structured-output mode (tools disabled, one LLM call, JSON response expected). The ReAct wrapper instructions ("Use your available tools as needed…") are incompatible with that mode and caused test_custom_schema[GOOGLE_AI] to fail — the function_response never materialised.

    Fix applied: the wrapper is now conditional — applied only when input_schema is set and output_schema is not. When both schemas are present, the raw JSON is passed through unchanged to preserve ADK's structured-output path.

    All tests pass:

    • test_custom_schema[GOOGLE_AI] — previously failing, now ✅
    • test_run_async_no_input_schema_passes_request_unchanged ✅
    • test_run_async_with_input_schema_wraps_in_natural_language ✅
    • test_run_async_with_input_schema_text_not_raw_json ✅
    • test_run_async_with_input_and_output_schema_passes_raw_json ✅ (new regression guard)
  7. surajksharma07 commented on Jun 4, 2026

    @surajksharma07
    Collaborator

    @ecanlar The conditional guard is technically correct.
    When output_schema is set, ADK configures the LLM request with response_schema + response_mime_type='application/json', putting the model in single-shot structured-output mode where tool calls are suppressed by the API — the ReAct wrapper would be semantically wrong here (telling the model to "use tools" when it's explicitly configured not to), not just unnecessary.
    The if input_schema and not output_schema condition precisely identifies the one case where the wrapper is both needed (raw JSON would be sent) and safe (ReAct loop is active). test_custom_schema[GOOGLE_AI] passing confirms the output_schema path is exercised and clean.

    On the lite_llm.py half: the FunctionCallingConfigMode → tool_choice mapping looks right — ANY → "required" forces Claude to call a tool, NONE → "none" blocks it, and omitting tool_choice for AUTO lets the provider default kick in (correct).
    The 4→5 tuple change with all 10 call sites updated and 302 + 11 tests passing gives strong confidence the change is complete.

    One sanity check worth doing before the final review: confirm that a request with tool_choice="required" but an empty tools list doesn't reach acompletion() — LiteLLM raises on that combination.
    The tools list is built from function_declarations, so if tools is None it stays None and tool_choice should also be None, but worth verifying that guard holds in the PR path.

    PR #5924 looks ready for review — both root causes addressed, conditional guard validated, all 11 new tests green.
    Could you drop a note on the PR once you've confirmed the empty-tools guard and we'll move this to ready to pull!

    Team is looking more into it.

  8. ecanlar commented on Jun 10, 2026

    @ecanlar
    ContributorAuthor
    1. In _get_completion_inputs (line 2086): after building tools from function_declarations, if not tools: tool_choice = None is applied before the tuple is returned. So if function_declarations is absent or empty, tool_choice is coerced to None right there.
    2. In the functions-fallback branch (line 2361–2363): when functions is in _additional_args, tools is explicitly set to None and tool_choice is cleared immediately after.
    3. At call time (line 2384): tool_choice is only added to completion_args when it is not None — so acompletion() is never called with tool_choice set and no tools list.
  9. ecanlar commented on Jun 10, 2026

    @ecanlar
    ContributorAuthor

    The guard is also covered by test_get_completion_inputs_tool_choice_coerced_to_none_when_no_tools (line 5347 in test_litellm.py), which exercises exactly the problematic scenario: mode=ANY (→ tool_choice="required") with no function_declarations — and asserts both that tools is falsy and that tool_choice is None.

  10. surajksharma07 commented on Jun 12, 2026

    @surajksharma07
    Collaborator

    @ecanlar Verified — the guard holds at all three layers: coercion at the source (if not tools: tool_choice = None in _get_completion_inputs), the functions-fallback branch clearing tool_choice together with tools, and the call-time conditional meaning acompletion() can never receive tool_choice without a tools list.

    The regression test (mode=ANY + no function_declarations → asserts both falsy tools and None tool_choice) covers exactly the combination LiteLLM rejects — that closes the last open question from my side.

    #5924 is now with the team for review — it's been assigned and a reviewer has been requested, so you'll hear back on the PR directly.
    Thanks for the thorough turnaround on the edge cases and the extra regression coverage!

  11. added a commit that references this issue on Jul 28, 2026
    550189c
  12. added a commit that references this issue on Aug 11, 2026
    56eb1c4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

models[Component] This issue is related to model supportrequest clarification[Status] The maintainer need clarification or more information from the author

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions