Skip to content

ADK_ENABLE_PROGRESSIVE_SSE_STREAMING causes excessive tool use by Gemini 3 #3974

Closed

Description

@ryanovas

Describe the bug
Continuation of #3705 (comment)

Turning on ADK_ENABLE_PROGRESSIVE_SSE_STREAMING=1 in .env causes Gemini 3 models to excessively use tools. It calls the same tool with different args 4 or 5 times in parallel, and will repeat for dozens of iterations before returning a response. It appears to duplicate tool invocations but that might be that this feature enables partial tool invocation events.

To Reproduce
Unfortunately I am tight for a deadline right now and can't take the time to properly build out a reproducible demo, but claude copilot put this together for me based on my repo which hopefully points in the right direction:

"""
Minimal reproduction of excessive tool usage with ADK_ENABLE_PROGRESSIVE_SSE_STREAMING=1

Issue: When progressive SSE streaming is enabled, Gemini makes excessive/duplicate 
tool calls even when instructed to limit to 3 calls per turn. With progressive streaming disabled, 
behavior is normal.

Architecture:
- Custom OrchestratorAgent (BaseAgent) routes to sub-agents (LlmAgents)
- Sub-agents have input_schema set to Pydantic models
- Sub-agents use BuiltInPlanner with ThinkingConfig
- Tools are given directly to sub-agents (not orchestrator)
"""

import asyncio
from google.adk.agents import Agent, BaseAgent, RunConfig
from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.run_config import StreamingMode
from google.adk.planners import BuiltInPlanner
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
from google.genai.types import Content
from pydantic import BaseModel, Field
from typing import Literal, AsyncGenerator
from google.adk.events import Event
import json


# Structured input for sub-agent
class UserPromptRequest(BaseModel):
    message_type: Literal['user_prompt'] = 'user_prompt'
    text: str = Field(..., description='User question')


# Simple tool that should be called once
async def search_knowledge(query: str) -> dict:
    """Search knowledge base for information."""
    return {"status": "success", "data": {"result": f"Found info about: {query}"}}


# Sub-agent with input schema and tools
sub_agent = Agent(
    name='knowledge_agent',
    description='Agent that searches knowledge and answers questions',
    model='gemini-3-flash-preview',
    instruction="""You are a helpful assistant. Use the search_knowledge tool to answer questions.
    
    CRITICAL: You are limited to a maximum of 2 tool calls per turn. Never make duplicate calls.""",
    planner=BuiltInPlanner(
        thinking_config=types.ThinkingConfig(
            include_thoughts=True,
            thinking_level=types.ThinkingLevel.HIGH,
        )
    ),
    input_schema=UserPromptRequest,
    tools=[search_knowledge],
)


# Custom orchestrator that routes to sub-agent
class OrchestratorAgent(BaseAgent):
    knowledge_agent: Agent
    
    def __init__(self, knowledge_agent: Agent, **kwargs):
        super().__init__(
            name='orchestrator',
            description='Routes messages to appropriate agent',
            knowledge_agent=knowledge_agent,
            sub_agents=[knowledge_agent],
            **kwargs
        )
    
    async def _run_async_impl(self, context: InvocationContext) -> AsyncGenerator[Event, None]:
        # Extract message from user content
        message_str = context.user_content.parts[0].text if context.user_content else ''
        message = json.loads(message_str)
        
        # Route to sub-agent
        async for event in self.knowledge_agent.run_async(context):
            yield event


async def test_streaming():
    root = OrchestratorAgent(knowledge_agent=sub_agent)
    session_service = InMemorySessionService()
    runner = Runner(agent=root, session_service=session_service)
    
    # Test with progressive SSE streaming enabled (set env var: ADK_ENABLE_PROGRESSIVE_SSE_STREAMING=1)
    run_config = RunConfig(
        response_modalities=['TEXT'],
        streaming_mode=StreamingMode.SSE,
    )
    
    # Structured message (like your WebSocket flow)
    message = json.dumps({
        "message_type": "user_prompt",
        "text": "What is Python programming?"
    })
    
    print("Testing with streaming_mode=StreamingMode.SSE")
    print("(Set ADK_ENABLE_PROGRESSIVE_SSE_STREAMING=1 to reproduce issue)\n")
    
    tool_call_count = 0
    async for event in runner.run_async(
        user_id='test_user',
        session_id='test_session',
        new_message=Content(role='user', parts=[{'text': message}]),
        run_config=run_config
    ):
        tool_calls = event.get_function_calls()
        if tool_calls:
            tool_call_count += len(tool_calls)
            for call in tool_calls:
                print(f"Tool call #{tool_call_count}: {call.name}({call.args})")
        
        if event.content and event.content.parts:
            for part in event.content.parts:
                if hasattr(part, 'text') and part.text:
                    print(f"Response: {part.text[:100]}...")
    
    print(f"\nTotal tool calls: {tool_call_count}")
    if tool_call_count > 2:
        print("⚠️  ISSUE REPRODUCED: Agent exceeded 2 tool call limit!")


if __name__ == '__main__':
    asyncio.run(test_streaming())

Claude believes this is related to using an OrchestratorAgent.

Expected behavior
Distinct tools should be executed in parallel, but the same tool should only be executed once following the instructions in the system prompt.

Desktop (please complete the following information):

  • OS: Ubuntu
  • Python version: 3.11
  • ADK version: 1.21

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: gemini 3 flash and pro

Activity

  1. ryanovas commented on Dec 19, 2025

    @ryanovas
    Author
  2. added
    live[Component] This issue is related to live, voice and video chat
    on Dec 19, 2025
  3. added theissue type on Dec 19, 2025
  4. added
    core[Component] This issue is related to the core interface and implementation
    tools[Component] This issue is related to tools
    and removed
    live[Component] This issue is related to live, voice and video chat
    on Dec 19, 2025
  5. self-assigned this
    on Dec 19, 2025
  6. xuanyang15 commented on Dec 19, 2025

    @xuanyang15
    Collaborator

    @ryanovas Thanks for creating this issue! If you check event.partial you will see the first function call is a partial event, where ADK doesn't call the function. Only the second one (non partial) will trigger an actually function call.

    For example if you change the print to be:

      tool_calls = event.get_function_calls()
        if tool_calls:
          tool_call_count += len(tool_calls)
          for call in tool_calls:
            print(
                f'Tool call (partial={event.partial}) #{tool_call_count}:'
                f' {call.name}({call.args})'
            )

    You will see:

    Tool call (partial=True) #1: search_knowledge({'query': 'what is python programming language definition features uses'})
    Tool call (partial=False) #2: search_knowledge({'query': 'what is python programming language definition features uses'})
    
  7. xuanyang15 commented on Dec 19, 2025

    @xuanyang15
    Collaborator

    You can also try adding a counter for the actually tool calls, e.g.

    search_knowledge_call_count = 0
    
    async def search_knowledge(query: str) -> dict:
      """Search knowledge base for information."""
      global search_knowledge_call_count
      search_knowledge_call_count += 1
      print(f'search_knowledge called: {search_knowledge_call_count}')
      return {'status': 'success', 'data': {'result': f'Found info about: {query}'}}
    
    ......
    
    print(f'\nTotal tool calls: {tool_call_count}')
    print(f'search_knowledge called: {search_knowledge_call_count} times')
    Image

    You will see the function is actually only being called once, because the first partial tool call event doesn't trigger real too calls.

  8. ryanovas commented on Dec 19, 2025

    @ryanovas
    Author

    I will try these later today and report back with the results

  9. ryanovas commented on Dec 21, 2025

    @ryanovas
    Author

    Ok I have filtered out the partial events and can confirm a good chunk of the invocations were partials.

    However it is still true that Gemini is much more eager to use tools with this enabled. Even with the partials filtered out it still seems to try and use many tools by default. I played around with my system prompts and have been able to reduce this to a more acceptable level, but every once in awhile it decides that it needs to use a billion tools and just... does.

    Without this enabled it seems like I need to adjust my prompts in order to make Gemini use tools in parallel and multiple times.

    With this enabled I need to adjust my prompts to make Gemini NOT use a ton of tools in parallel and multiple times, and it never really seems to respect a maximum - I can just sort of change what it considers "enough" to form a response.

  10. xuanyang15 commented on Dec 22, 2025

    @xuanyang15
    Collaborator

    Hi @ryanovas, Thanks for the information! What do you mean by "Gemini is much more eager to use tools with this enabled"? I don't think PROGRESSIVE_SSE_STREAMING will impact model's decision to use tools since PROGRESSIVE_SSE_STREAMING is only changing how ADK interprets model's streaming responses (and the partial function calls are not stored into sessions nor sent back to models).

    Can you try turning the streaming mode on and off (i.e. using the same model but without SSE streaming) to see if there is any difference in terms of how eager the model is to use tools?

  11. ryanovas commented on Dec 22, 2025

    @ryanovas
    Author
  12. xuanyang15 commented on Dec 22, 2025

    @xuanyang15
    Collaborator

    Do you have some example code for me to reproduce this behavior?

  13. adk-bot commented on Jan 6, 2026

    @adk-bot
    Collaborator

    This issue has been automatically marked as stale because it has not had recent activity for 7 days after a maintainer requested clarification. It will be closed if no further activity occurs within 7 days.

  14. added
    stale[Status] Issues which have been marked inactive since there is no user response
    request clarification[Status] The maintainer need clarification or more information from the author
    on Jan 6, 2026
  15. mijinatdiscovery commented on Jan 10, 2026

    @mijinatdiscovery

    I’m seeing a similar issue. I explicitly set streaming_mode to None:

    run_config = RunConfig(
        streaming_mode=None,
    )
    

    Streaming still appears to be enabled in production, it works fine in the local env.
    How can I disable streaming_mode?

  16. xuanyang15 commented on Jan 10, 2026

    @xuanyang15
    Collaborator

    @mijinatdiscovery Thanks for reporting this issue!

    Could you please provide more information?

    • more details about the issue you saw
    • where is your production code deployed and how it is deployed
    • an example to reproduce the issue will be great
  17. adk-bot commented on Jan 13, 2026

    @adk-bot
    Collaborator

    This has been automatically closed because it has been marked as stale for over 7 days.

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

Metadata

Metadata

Assignees

Labels

core[Component] This issue is related to the core interface and implementationrequest clarification[Status] The maintainer need clarification or more information from the authorstale[Status] Issues which have been marked inactive since there is no user responsetools[Component] This issue is related to tools

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions