Repository navigation
fix: AgentTool with input_schema + LiteLLM/Claude skips inner agent tool calls #5926
Description
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_choiceNote: 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-6via LiteLLM (LiteLlm("anthropic/claude-sonnet-4-6")) - Python: 3.12
Related issues
- Model Settings: Add Tool Choice ["auto", "required", "none"] #773 — Model Settings: Add Tool Choice (open) — Cause 2 directly addresses this
- input_schema does not work, even with the simplest possible BaseModel class #1063 —
input_schemadoes not work (closed in v1.20.0) — fix there correctedFunctionDeclarationdescription but not the tool-calling behaviour described here
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
fix(agent_tool): wrap input_schema JSON in ReAct prompt; propagate to…
surajksharma07 commentedon Jun 1, 2026
@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!
ecanlar commentedon Jun 2, 2026
The wrapper works as expected when only
input_schemais set — Claude stays in ReAct mode correctly.However, when
output_schemais set alongsideinput_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 causedtest_custom_schema[GOOGLE_AI]to fail — thefunction_responsenever materialised.Fix applied: the wrapper is now conditional — applied only when
input_schemais set andoutput_schemais 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)surajksharma07 commentedon Jun 4, 2026
@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.
ecanlar commentedon Jun 10, 2026
ecanlar commentedon Jun 10, 2026
The guard is also covered by
test_get_completion_inputs_tool_choice_coerced_to_none_when_no_tools(line 5347 intest_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 thattool_choiceis None.surajksharma07 commentedon Jun 12, 2026
@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!
fix: wrap input_schema payload in ReAct prompt and propagate tool_cho…
fix: wrap input_schema payload in ReAct prompt and propagate tool_cho…