Skip to content

output_schema agent with only agent transfer gets JSON mode plus transfer_to_agent on models that can't combine them #7717

Description

@Somay-kousis

Describe the bug

An LlmAgent with output_schema and no tools of its own still gets the transfer_to_agent function tool whenever it has transfer targets. That is the default for any sub-agent under an LlmAgent coordinator (transfer to parent and peers is allowed by default), and for a coordinator with sub_agents.

On a model whose capabilities.output_schema_and_tools is False (for example Gemini through the Gemini Developer API, where gemini_output_schema_and_tools is False outside Vertex AI), ADK should route the structured output through the set_model_response tool, the same way it does when the agent has regular tools. It does not. The request carries response_mime_type='application/json' plus response_schema and also declares transfer_to_agent. That is the pairing ADK avoids for agents with regular tools; #5735 reports Gemini rejecting it with 400 INVALID_ARGUMENT: Function calling with a response mime type: 'application/json' is unsupported.

Both gates only look at agent.tools and ignore the transfer tool that _AgentTransferLlmRequestProcessor adds later:

  • src/google/adk/flows/llm_flows/basic.py:138: if not agent.tools or model.capabilities.output_schema_and_tools: sets the native output schema.
  • src/google/adk/flows/llm_flows/prompt/_schema.py:50: or not agent.tools returns early, so set_model_response is never added.

#3318 removed the old rule that forced disallow_transfer_to_parent/peers=True when output_schema was set, on the premise that set_model_response works with any tools, including agent transfer. These two gates were not widened, so that path never turns on for an agent whose only function tool is the transfer tool.

Steps to reproduce

No network or API key needed. The model is a local fake that records its requests and reports the default output_schema_and_tools=False.

  1. From the adk-python repo root, save the script below as repro_output_schema_transfer.py.
  2. Run PYTHONPATH=src python repro_output_schema_transfer.py.
import asyncio
from typing import AsyncGenerator
import warnings

warnings.filterwarnings('ignore')

from google.adk.agents.llm_agent import LlmAgent
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.genai import types
from pydantic import BaseModel


class FakeLlm(BaseLlm):
  replies: list[types.Content] = []
  requests: list[LlmRequest] = []

  async def generate_content_async(
      self, llm_request: LlmRequest, stream: bool = False
  ) -> AsyncGenerator[LlmResponse, None]:
    self.requests.append(llm_request)
    yield LlmResponse(content=self.replies.pop(0))


class Verdict(BaseModel):
  label: str


def declared_functions(req: LlmRequest) -> list[str]:
  return [
      fd.name
      for tool in (req.config.tools or [])
      for fd in (tool.function_declarations or [])
  ]


async def main() -> None:
  root_model = FakeLlm(
      model='fake-root',
      replies=[
          types.Content(
              role='model',
              parts=[
                  types.Part.from_function_call(
                      name='transfer_to_agent',
                      args={'agent_name': 'classifier'},
                  )
              ],
          )
      ],
  )
  classifier_model = FakeLlm(
      model='fake-classifier',
      replies=[
          types.Content(role='model', parts=[types.Part(text='{"label": "spam"}')])
      ],
  )
  classifier = LlmAgent(
      name='classifier', model=classifier_model, output_schema=Verdict
  )
  helper = LlmAgent(name='helper', model=FakeLlm(model='fake-helper'))
  root = LlmAgent(name='root', model=root_model, sub_agents=[classifier, helper])

  session_service = InMemorySessionService()
  runner = Runner(app_name='app', agent=root, session_service=session_service)
  session = await session_service.create_session(app_name='app', user_id='u')
  async for _ in runner.run_async(
      user_id='u',
      session_id=session.id,
      new_message=types.Content(role='user', parts=[types.Part(text='hi')]),
  ):
    pass

  req = classifier_model.requests[-1]
  print(
      'classifier capabilities.output_schema_and_tools =',
      classifier_model.capabilities.output_schema_and_tools,
  )
  print('response_mime_type =', req.config.response_mime_type)
  print('response_schema set =', req.config.response_schema is not None)
  print('function declarations =', declared_functions(req))
  conflict = req.config.response_mime_type == 'application/json' and bool(
      declared_functions(req)
  )
  print('JSON mode + function calling in one request =', conflict)


asyncio.run(main())

Expected behavior

On a model that cannot pair an output schema with function calling, the classifier's request should not set JSON mode. It should declare set_model_response next to transfer_to_agent and add the set_model_response instruction, as it already does when the agent has any regular tool.

Observed behavior

classifier capabilities.output_schema_and_tools = False
response_mime_type = application/json
response_schema set = True
function declarations = ['transfer_to_agent']
JSON mode + function calling in one request = True

ADK's own capability check says this model can't take that pairing, and #5735 reports the 400 INVALID_ARGUMENT Gemini returns for it. I didn't run a live call. The same agent with one ordinary tool added gets set_model_response and no JSON mode, so only the transfer-only case is affected.

Environment

  • ADK: main at 12c7bed7661ccf942f0f6d3b62bed22a23ea0a85
  • Python: 3.12.13
  • OS: macOS
  • Model: any model reporting output_schema_and_tools=False (Gemini Developer API, several LiteLLM providers). Reproduced with a local fake model, no live API call.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions