⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content

Conversation

@sajanlamsal
Copy link

@sajanlamsal sajanlamsal commented Jan 13, 2026

Issue

Bug #4133: The Automatic Function Calling (AFC) loop was ignoring AutomaticFunctionCallingConfig settings, specifically disable=True and maximum_remote_calls. This caused function calls to continue executing even when explicitly disabled or limited, leading to unexpected behavior and potential infinite loops.

Overview

This PR addresses two critical issues in the Automatic Function Calling (AFC) system:

  1. Bug Fix (Bug: AutomaticFunctionCallingConfig(disable=True) is ignored - Planner hook bypassed #4133): AFC configuration settings (disable=True, maximum_remote_calls) were being completely ignored

  2. Guardrail Enhancement: Prevents infinite loops when LLMs persistently return function calls after reaching the maximum_remote_calls limit

    Real-world scenario: An LLM searches for "unicorn startups in Antarctica" using a search tool. The tool returns empty results (no such companies exist), but the LLM keeps calling the search function with slight variations hoping to find results. Without the guardrail, the loop continues indefinitely. With the guardrail, after 3 consecutive refused calls, the system forces a final text response like "I couldn't find any unicorn startups in Antarctica based on my search."

Together, these changes provide robust control over AFC behavior and prevent runaway execution in edge cases.


Part 1: AFC Configuration Bug Fix

Issue Summary

Bug #4133: The AFC loop was ignoring AutomaticFunctionCallingConfig settings entirely, causing function calls to execute even when explicitly disabled or limited.

Problem Details

Before this fix:

  • Setting AutomaticFunctionCallingConfig(disable=True) had no effect - AFC loop continued
  • Setting maximum_remote_calls=0 was ignored - function calls still executed
  • Setting maximum_remote_calls=N was ignored - loop continued beyond the limit
  • Issue affected both run_async() and run_live() execution modes

Root Cause:
The AFC loop in base_llm_flow.py had no checks for AutomaticFunctionCallingConfig settings. It would blindly continue the while True loop and execute all function calls regardless of configuration.

Solution: Configuration Enforcement

Added comprehensive AFC configuration checking through:

  1. New Helper Function: _should_stop_afc_loop()

    • Centralized logic for checking AFC config
    • Handles both disable flag and maximum_remote_calls limit
    • Dual-use parameter count_current_event for loop vs. pre-execution checks
    • Includes event counting semantics documentation
  2. Loop Termination Check (run_async)

    • Added check in run_async() loop to break when AFC should stop
    • Prevents starting another iteration when disabled or limit reached
  3. Pre-Execution Checks

    • Added check in _postprocess_async() to prevent FC execution
    • Added check in _postprocess_live() for live streaming mode
    • Ensures FCs are not executed even if loop continues

Key Features

Event Counting Semantics:

  • maximum_remote_calls counts LLM response events containing function calls
  • NOT individual function executions
  • An event with 3 parallel function calls counts as 1 toward the limit

Asymmetric Helper Behavior:

  • When count_current_event=False (loop check): Checks disable flag + tracks consecutive refused FCs for guardrail
  • When count_current_event=True (pre-execution): Checks both disable AND maximum_remote_calls

Design Rationale:
The asymmetric behavior ensures the loop continues to get a final response from the LLM even after reaching the limit, but prevents additional function calls from executing. The loop check also tracks consecutive refused function calls to trigger the guardrail mechanism when needed.


Part 2: Guardrail Mechanism

Problem Statement

Issue: When an LLM reaches maximum_remote_calls, it should stop making function calls. However, some models continue returning function calls even after the limit is reached. The original code had no mechanism to force a final text response, leading to:

  1. Infinite Loop Risk: LLM keeps returning FCs → ADK refuses to execute them → LLM returns more FCs → repeat
  2. Poor User Experience: User never receives a closing response
  3. Resource Waste: Unnecessary LLM calls consuming quota

Root Cause in Original Code:

# Original run_async() - NO GUARDRAIL
async def run_async(self, invocation_context: InvocationContext):
    while True:
        last_event = None
        async with Aclosing(self._run_one_step_async(invocation_context)) as agen:
            async for event in agen:
                last_event = event
                yield event
        
        # ONLY CHECK: is_final_response() or partial
        if not last_event or last_event.is_final_response() or last_event.partial:
            if last_event and last_event.partial:
                logger.warning('The last event is partial, which is not expected.')
            break
        # No handling for persistent FCs after max_calls!

The loop would continue indefinitely if the LLM never returned is_final_response().

Solution: Guardrail Mechanism

Core Concept

The guardrail is a protective mechanism that triggers after 3 consecutive refused function calls (MAX_CONSECUTIVE_REFUSED_FUNCTION_CALLS). When triggered:

  1. One Final LLM Call: System makes ONE final attempt with modified settings:

    • Tools are removed from the request
    • AFC is explicitly disabled
    • System instruction added forcing text response
  2. Forced Termination: After this final iteration, the loop terminates regardless of LLM output

  3. Comprehensive Logging: Detailed diagnostics about guardrail effectiveness

Implementation Details

1. Constants

Two key constants control guardrail behavior:

MAX_CONSECUTIVE_REFUSED_FUNCTION_CALLS = 3
"""Max consecutive FCs after hitting maximum_remote_calls before guardrail."""

_GUARDRAIL_INSTRUCTION = (
    '\n\n**IMPORTANT: You have reached the maximum number of function '
    'calls allowed. You MUST provide a final text response to the user '
    'now. DO NOT attempt to call any more functions. Summarize what you '
    'have learned so far and provide a helpful response based on the '
    'information already gathered.**'
)
"""System instruction added during guardrail to force text response."""

Design Choice: The threshold of 3 consecutive refusals balances between giving the LLM reasonable attempts to provide a text response versus preventing excessive resource consumption.

2. GuardrailContext Class

Purpose: Encapsulate guardrail state management and eliminate magic string keys.

class GuardrailContext:
  """Manages guardrail state coordination between flow methods."""
  
  def __init__(self, session_state: dict[str, Any]):
    self._state = session_state
    self._active_key = '_adk_guardrail_active'
    self._processed_key = '_adk_guardrail_processed'

  @property
  def is_active(self) -> bool:
    """True if guardrail should be applied to next LLM call."""
    
  @property
  def is_processed(self) -> bool:
    """True if preprocessing already handled guardrail."""

  def activate(self) -> None:
    """Mark guardrail as active for next LLM call."""

  def mark_processed(self) -> None:
    """Mark that preprocessing handled guardrail."""

  def clear_active(self) -> None:
    """Clear active flag only."""

  def clear_processed(self) -> None:
    """Clear processed flag only."""

  def clear(self) -> None:
    """Clear all guardrail flags."""

  def __repr__(self) -> str:
    """Return debug representation."""

Key Design Decisions:

  • Session State Storage: Flags stored in session.state to survive across method boundaries
  • Two-Phase Protocol: is_active → preprocessing → is_processed → final enforcement
  • Encapsulation: No direct state manipulation; all access through public methods
  • Single Instance: One instance created in run_async(), stored in invocation_context._guardrail
  • Debug-Friendly: __repr__() for easy debugging

3. Implementation Overview

Helper Functions:

  • _count_function_call_events(): Counts FC events in current invocation with fail-safe error handling (returns None on error)
  • _event_has_function_calls(): Checks if event contains FCs with graceful error handling
  • _should_stop_afc_loop(): Enhanced to support both AFC config checking AND guardrail tracking
    • Dual responsibility: Checks AFC config (disable, maximum_remote_calls) AND tracks consecutive refused FCs
    • Returns tuple: (should_stop: bool, new_consecutive_count: int)
    • Asymmetric behavior based on count_current_event parameter (loop check vs pre-execution check)

4. Modified Flow Methods

run_async() Changes:

  • Initialize GuardrailContext and attach to invocation_context._guardrail
  • Track consecutive_refused_fcs counter across iterations
  • Call _should_stop_afc_loop() after each step to check AFC config and update refusal counter
  • When consecutive_refused_fcs >= 3: Trigger guardrail (set flag, continue to final iteration)
  • When guardrail_triggered=True: Analyze final LLM response, log diagnostics, force break
  • Cleanup guardrail flags in finally block

_preprocess_async() Changes:

  • Check if guardrail.is_active and not yet processed
  • If active: Add _GUARDRAIL_INSTRUCTION to system instruction (append if exists, set if empty)
  • If active: Set llm_request.config.automatic_function_calling.disable = True
  • Mark guardrail as processed and clear active flag
  • Skip normal tool processing (early return)

_run_one_step_async() Changes:

  • Check if guardrail.is_processed (final safety net before LLM call)
  • If processed: Force remove all tools from llm_request.config.tools
  • If processed: Ensure AFC is disabled
  • Clear processed flag after enforcement

_postprocess_live() Changes (Live Mode Support):

  • Call _should_stop_afc_loop() with count_current_event=True before executing FCs
  • If should_stop=True: Skip FC execution (early return)
  • If should_stop=False: Proceed with normal FC execution

Note: Live mode only has pre-execution checks, not the full guardrail mechanism, because the Gemini Live API controls the loop externally.


Comparison: Before vs After

Before (Original Code - No AFC Config Enforcement, No Guardrail)

async def run_async(self, invocation_context: InvocationContext):
    """Runs the flow."""
    while True:
        last_event = None
        async with Aclosing(self._run_one_step_async(invocation_context)) as agen:
            async for event in agen:
                last_event = event
                yield event
        
        # ONLY CHECK: is_final_response() or partial
        if not last_event or last_event.is_final_response() or last_event.partial:
            if last_event and last_event.partial:
                logger.warning('The last event is partial, which is not expected.')
            break

Problems:

  • ❌ No AFC config checking (disable, maximum_remote_calls completely ignored)
  • ❌ No handling of persistent FCs after maximum_remote_calls
  • ❌ No counter tracking consecutive refusals
  • ❌ No guardrail mechanism to force text response
  • ❌ Loop could run indefinitely if LLM never returns final response
  • ❌ No diagnostics about why loop continues

After (With AFC Config Enforcement + Guardrail)

async def run_async(self, invocation_context: InvocationContext):
    """Runs the flow."""
    # Build config for checks
    llm_request: LlmRequest = LlmRequest()
    agent: BaseAgent = invocation_context.agent
    llm_request.config = (
        agent.generate_content_config.model_copy(deep=True)
        if agent.generate_content_config
        else types.GenerateContentConfig()
    )
    
    consecutive_refused_fcs: int = 0
    guardrail_triggered: bool = False
    
    guardrail = GuardrailContext(invocation_context.session.state)
    invocation_context._guardrail = guardrail
    
    try:
        while True:
            # ... run one step ...
            
            # GUARDRAIL CHECK (takes precedence)
            if guardrail_triggered:
                # Comprehensive diagnostics about final iteration
                # Extract text content, check for FCs
                # Log success/failure of guardrail
                break  # FORCED BREAK after guardrail iteration
            
            # Normal termination checks
            if not last_event or last_event.is_final_response() or last_event.partial:
                if last_event and last_event.partial:
                    logger.warning('The last event is partial, which is not expected.')
                break
            
            # AFC CONFIG CHECK + CONSECUTIVE REFUSAL TRACKING
            should_stop, consecutive_refused_fcs = _should_stop_afc_loop(
                llm_request,
                invocation_context,
                last_event,
                count_current_event=False,
                consecutive_refused_fcs=consecutive_refused_fcs,
                guardrail_in_progress=guardrail_triggered,
            )
            
            if should_stop:
                if guardrail_triggered:
                    # Already in guardrail iteration - force break
                    logger.warning('LLM returned FCs even during guardrail...')
                    break
                
                # Check if this is guardrail trigger (vs normal stop)
                if consecutive_refused_fcs >= MAX_CONSECUTIVE_REFUSED_FUNCTION_CALLS:
                    # TRIGGER GUARDRAIL
                    logger.info('Guardrail: removing tools for final LLM call...')
                    guardrail_triggered = True
                    guardrail.activate()
                    # Continue to final iteration (don't break)
                else:
                    # Normal stop (AFC disabled or invalid config)
                    break
    finally:
        # Cleanup: ensure guardrail cleared even if loop exits early
        guardrail.clear()

Improvements:

  • ✅ Respects AFC config (disable=True, maximum_remote_calls)
  • ✅ Tracks consecutive refused FCs with persistent counter
  • ✅ Triggers guardrail after 3 consecutive refusals
  • ✅ Forces final text response via system instruction + disabled tools
  • ✅ Comprehensive diagnostics about guardrail effectiveness
  • ✅ Guaranteed loop termination after guardrail iteration
  • ✅ Fail-safe cleanup in finally block
  • ✅ Proper error handling with specific exceptions + exc_info=True
  • ✅ Debug-friendly __repr__() for GuardrailContext

Observability Improvements

New Log Messages

Info Level:

INFO - automatic_function_calling is disabled. Stopping AFC loop.
INFO - Guardrail triggered: 3 consecutive FCs after max=3. Forcing text response.
INFO - Guardrail: removing tools for final LLM call to force text.
INFO - Guardrail: skipping tools, disabling AFC for text response
INFO - Guardrail: LLM returned text response (also included function calls which were ignored).

Warning Level:

WARNING - max_remote_calls in automatic_function_calling_config 0 is less than or equal to 0. Disabling automatic function calling.
WARNING - `automatic_function_calling.disable` is set to `True`. And `automatic_function_calling.maximum_remote_calls` is a positive number 3. Disabling automatic function calling.
WARNING - Would exceed max_remote_calls=3. Not executing FCs.
WARNING - Guardrail yielded no response. User may not have received closing message.
WARNING - Guardrail: LLM still returned only function calls despite tools being disabled. User will not receive response.
WARNING - Guardrail: LLM returned empty response.
WARNING - LLM returned function calls even during guardrail final iteration (count=X). Ending AFC loop.

Debug Level:

DEBUG - LLM returned FCs after limit (3/3). max=3, count=3
DEBUG - Guardrail final enforcement: tools removed, AFC disabled.

Error Level (with exc_info=True):

ERROR - Error counting FC events: <exception>
ERROR - Error checking FCs in event: <exception>
ERROR - Error checking function calls in guardrail iteration: <exception>
ERROR - FC count failed. Stopping AFC loop (fail safe).
ERROR - FC check failed. Preventing execution (fail safe).

Files Changed

Modified Files

  1. src/google/adk/flows/llm_flows/base_llm_flow.py
    • Added GuardrailContext class (43 lines)
    • Added core logic: _should_stop_afc_loop() (92 lines) - enhanced for both AFC config and guardrail
    • Added helper functions: _count_function_call_events(), _event_has_function_calls(), _check_afc_disabled(), _check_max_calls_invalid()
    • Modified: run_async() (added AFC config checks + guardrail tracking and final iteration handling)
    • Modified: _preprocess_async() (added guardrail activation logic)
    • Modified: _postprocess_async() (added AFC config pre-execution check)
    • Modified: _postprocess_live() (added AFC config pre-execution check)
    • Modified: _run_one_step_async() (added guardrail final enforcement)
    • Enhanced docstrings with event counting semantics

Added Test Files

  1. tests/unittests/flows/llm_flows/test_afc_config.py (1,079 lines)

    • 20 comprehensive tests covering AFC configuration enforcement
    • Pytest fixture for live mode tests to reduce code duplication
    • Covers both run_async() and run_live() execution modes
    • Tests: disable flag, maximum_remote_calls limits (0, 1, 2, negative, large), parallel FCs, corrupted sessions, planner hooks
  2. tests/unittests/flows/llm_flows/test_guardrail.py (494 lines)

    • 12 comprehensive tests covering guardrail functionality
    • Tests state management, system instruction injection, tool skipping, AFC disabling, flag coordination, final enforcement, and error handling
    • Includes live mode tests verifying _postprocess_live() pre-execution checks and AFC limit enforcement

Testing

Test Coverage Summary

Test File Tests Lines Coverage
test_afc_config.py 20 1,079 AFC config enforcement (both modes)
test_guardrail.py 12 494 Guardrail mechanism + live mode checks
Total 32 1,573 Comprehensive

AFC Configuration Tests (test_afc_config.py)

20 tests covering:

Core Functionality Tests

  • test_afc_disabled_stops_loop - Verifies disable=True stops AFC loop
  • test_maximum_remote_calls_zero_stops_loop - Verifies maximum_remote_calls=0 stops loop
  • test_maximum_remote_calls_limit_enforced - Verifies limit=2 allows exactly 2 FCs
  • test_planner_hook_called_with_maximum_remote_calls_zero - Ensures planner hooks still called
  • test_afc_enabled_continues_loop - Verifies normal AFC operation preserved

Edge Cases

  • test_negative_maximum_remote_calls_treated_as_zero - Negative values caught by <= 0 check
  • test_very_large_maximum_remote_calls - Very large limits (999999) work correctly
  • test_corrupted_session_empty_events - Corrupted/empty session handled gracefully

Parallel Function Calls

  • test_afc_disabled_with_parallel_function_calls - AFC disable works with parallel FCs
  • test_maximum_remote_calls_with_parallel_function_calls - Confirms event counting (not FC counting)
  • test_parallel_function_calls_in_live_mode - Verifies parallel FC counting in live mode

Live Mode Coverage

All scenarios tested in both run_async() and run_live() modes:

  • test_afc_disabled_in_live_mode
  • test_maximum_remote_calls_in_live_mode
  • test_maximum_remote_calls_zero_in_live_mode
  • test_parallel_function_calls_in_live_mode
  • test_negative_maximum_remote_calls_in_live_mode
  • test_maximum_remote_calls_two_in_live_mode
  • test_very_large_maximum_remote_calls_in_live_mode
  • test_corrupted_session_in_live_mode
  • test_planner_hooks_in_live_mode

Guardrail Tests (test_guardrail.py)

12 tests covering:

Core Guardrail Tests (9):

  1. test_guardrail_constants_defined - Verifies constants exist with correct values
  2. test_guardrail_context - Tests all GuardrailContext methods including __repr__()
  3. test_guardrail_instruction_added_to_empty_system_instruction - System instruction injection
  4. test_guardrail_instruction_appended_to_existing_instruction - Append behavior
  5. test_guardrail_skips_tool_addition - Tools not processed when guardrail active
  6. test_guardrail_disables_afc - AFC config modification verified
  7. test_guardrail_sets_processed_flag - Flag coordination between methods
  8. test_guardrail_final_enforcement_removes_tools - Final safety net in _run_one_step_async()
  9. test_guardrail_cleans_up_flags_on_error - Error handling and cleanup with try/finally

Live Mode Tests (3):
10. test_guardrail_live_mode_pre_execution_check - Verifies _postprocess_live() stops FC execution when limit exceeded
11. test_guardrail_live_mode_allows_execution_below_threshold - Confirms FCs execute normally below threshold in live mode
12. test_guardrail_live_mode_respects_afc_disable - Ensures AFC disable flag is honored in live streaming mode

Test Results

# AFC config tests
pytest tests/unittests/flows/llm_flows/test_afc_config.py -v
# 20 passed, 41 warnings in 1.89s

# Guardrail tests
pytest tests/unittests/flows/llm_flows/test_guardrail.py -v
# 12 passed in 1.48s

# All base_llm_flow tests (no regressions)
pytest tests/unittests/flows/llm_flows/test_base_llm_flow.py -v
# 18 passed

# All llm_flows tests combined
pytest tests/unittests/flows/llm_flows/ -q
# 329 passed, 222 warnings in 1.96s

# Full test suite - no regressions
pytest tests/unittests/ -v
# 3,832 passed, 2,151 warnings in 88.94s

Manual E2E Testing

Manual E2E verification performed with 3 scenarios:

Scenario 1: disable=True, maximum_remote_calls=0

Config:

AutomaticFunctionCallingConfig(
    disable=True,
    maximum_remote_calls=0
)

Expected: No function calls executed, loop stops immediately.

Actual (from logs):

14:04:40 - WARNING - max_remote_calls in automatic_function_calling_config 0 is less than or equal to 0. Disabling automatic function calling.
14:04:45 - WARNING - base_llm_flow.py:115 - automatic_function_calling is disabled. Stopping AFC loop.
14:04:45 - INFO - Generated 2 events in agent run

Result: ✅ PASS - Loop stopped, no tool calls executed.


Scenario 2: disable=True, maximum_remote_calls=3

Config:

AutomaticFunctionCallingConfig(
    disable=True,
    maximum_remote_calls=3
)

Expected: disable=True takes precedence, no function calls executed.

Actual (from logs):

14:06:34 - WARNING - `automatic_function_calling.disable` is set to `True`. And `automatic_function_calling.maximum_remote_calls` is a positive number 3. Disabling automatic function calling.
14:06:37 - WARNING - base_llm_flow.py:115 - automatic_function_calling is disabled. Stopping AFC loop.
14:06:37 - INFO - Generated 1 events in agent run

Result: ✅ PASS - disable=True correctly takes precedence.


Scenario 3: disable=False, maximum_remote_calls=3

Config:

AutomaticFunctionCallingConfig(
    disable=False,
    maximum_remote_calls=3
)

Expected: Exactly 3 function call events executed, then loop stops.

Actual (from logs):

14:10:28 - INFO - job_search_tool.py:106 - Job Search Filter: location_cities: ANY("Tokyo")
14:10:33 - INFO - job_search_tool.py:106 - Job Search Filter: location_countries: ANY("Japan")
14:10:38 - INFO - job_search_tool.py:106 - Job Search Filter: location_cities: ANY("Tokyo")
14:10:50 - INFO - Generated 7 events in agent run

Comparison: Without fix, this loop generated 23+ events

Result: ✅ PASS - Exactly 3 tool calls executed, then stopped.


Scenario 4: Guardrail Mechanism Triggered (Persistent Function Calls)

Config:

AutomaticFunctionCallingConfig(
    disable=False,
    maximum_remote_calls=3
)

Scenario: LLM persistently returns function calls even after reaching the limit (simulated misbehaving LLM).

Expected: After 3 consecutive refused function calls, guardrail triggers and forces final text response.

Actual (from logs):

15:23:10 - INFO - Iteration 1: Executing function call to search_tool
15:23:12 - INFO - Iteration 2: Executing function call to search_tool
15:23:14 - INFO - Iteration 3: Executing function call to search_tool
15:23:16 - DEBUG - LLM returned FCs after limit (1/3). max=3, count=3
15:23:18 - DEBUG - LLM returned FCs after limit (2/3). max=3, count=3
15:23:20 - DEBUG - LLM returned FCs after limit (3/3). max=3, count=3
15:23:20 - INFO - Guardrail triggered: 3 consecutive FCs after max=3. Forcing text response.
15:23:20 - INFO - Guardrail: removing tools for final LLM call to force text.
15:23:21 - INFO - Guardrail: skipping tools, disabling AFC for text response
15:23:23 - INFO - Guardrail: LLM returned text response (also included function calls which were ignored).
15:23:23 - INFO - Generated 11 events in agent run

Result: ✅ PASS - Guardrail triggered after 3 consecutive refusals, forced final text response, loop terminated.

Comparison:

  • Without AFC fix (original bug): Would execute FCs indefinitely (23+ iterations observed)
  • With AFC fix only (no guardrail): Would stop executing FCs after limit=3, then loop terminates - user receives no final response
  • With AFC fix + guardrail: Stops executing FCs after limit=3, then forces final text response after 3 consecutive refusals - user gets proper closing message

Breaking Changes

None. These changes:

  • Fix bugs to make the API behave as originally intended and documented
  • Add protective mechanisms that only activate in edge cases
  • Do not change the existing public API

Existing code will benefit from the fixes without any changes required.

Documentation Changes

No user-facing documentation changes required. These are bug fixes and protective enhancements that make the existing API work as documented.

Additional Notes

Event Counting Behavior

Important semantic clarification: maximum_remote_calls counts LLM response events containing function calls, not individual function executions. This means:

  • 1 event with 3 parallel function calls = counts as 1 toward the limit
  • 3 events with 1 function call each = counts as 3 toward the limit

This behavior is now documented in the _should_stop_afc_loop() docstring.

Design Decision: Guardrail vs. Pre-execution Check

Two complementary mechanisms:

  1. Pre-execution Check (Part 1): Prevents FC execution when config says to stop

    • Applied in both run_async() and run_live()
    • Respects user configuration immediately
  2. Guardrail (Part 2): Forces final text response when LLM ignores limits

    • Only in run_async() (loop controlled by ADK)
    • Handles misbehaving LLMs that ignore the limit

Asymmetric Helper Behavior

The _should_stop_afc_loop() function has intentionally different behavior based on count_current_event:

  • Loop continuation check (count_current_event=False): Checks disable flag + tracks consecutive refused FCs for guardrail
  • Pre-execution check (count_current_event=True): Checks both disable AND maximum_remote_calls

Rationale: This design ensures:

  1. Loop continues to get a final response from LLM even after reaching limit
  2. Function calls are prevented from executing when limit is reached
  3. User gets a proper final response instead of abrupt termination
  4. Guardrail triggers if LLM persists with FCs despite reaching limit

Performance Impact

Negligible:

  • Helper functions: O(n) event scanning (similar scanning already done elsewhere)
  • GuardrailContext: O(1) dictionary lookups
  • Only adds logic when near/at maximum_remote_calls limit
  • Reduces resource waste by preventing infinite loops

Future Enhancements

  1. Configurable Threshold: Make MAX_CONSECUTIVE_REFUSED_FUNCTION_CALLS a runtime parameter via RunConfig
  2. Retry Strategies: Different guardrail approaches (e.g., remove most recent tools only, progressive tool removal)
  3. Metrics Export: Export guardrail trigger rate for monitoring/alerting
  4. Custom Instructions: Allow per-agent guardrail system instructions
  5. Telemetry Integration: Add guardrail events to telemetry/tracing

Checklist

  • Created comprehensive unit tests (32 tests total: 20 AFC config + 12 guardrail)
  • All tests passing (3,832 total, including 329 llm_flows tests)
  • No regressions introduced
  • Code formatted with isort and pyink
  • Both execution modes covered (run_async and run_live)
  • Documentation enhanced (docstrings with event counting semantics)
  • Manual E2E testing completed (logs included)
  • Wheel file built and tested locally
  • Wheel verification passed

Related Issues

Fixes #4133

Tested Wheel: google_adk-1.22.1-py3-none-any.whl

The fix is production-ready and can be deployed immediately. All edge cases have been tested, and the implementation is backward compatible.


…dling

- Implement tests to verify behavior when AFC is disabled, including stopping the loop after the first response.
- Validate that maximum_remote_calls configuration is respected, including edge cases for zero and negative values.
- Ensure planner hooks are called correctly with various AFC settings.
- Test AFC behavior in live streaming mode, confirming that configurations are enforced as expected.
- Cover scenarios with parallel function calls and their impact on AFC limits.
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @sajanlamsal, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a critical bug where Automatic Function Calling (AFC) configuration settings, such as disabling AFC or limiting the number of remote calls, were being ignored. The fix introduces robust checks throughout the LLM flow to ensure these settings are respected, preventing unintended function executions and potential infinite loops. This change significantly improves the reliability and predictability of AFC behavior, aligning it with the intended API specification.

Highlights

  • New AFC Configuration Helper: Introduced _should_stop_afc_loop() to centralize logic for checking disable and maximum_remote_calls settings for Automatic Function Calling (AFC).
  • Asynchronous Loop Termination: Implemented a check in run_async() to break the Automatic Function Calling (AFC) loop when configuration limits are met or AFC is explicitly disabled.
  • Pre-Execution Function Call Prevention: Added checks in _postprocess_async() and _postprocess_live() to prevent function calls from executing if AFC is disabled or the maximum_remote_calls limit has been reached.
  • Clarified Event Counting Semantics: Documented that maximum_remote_calls counts LLM response events containing function calls, not individual function executions, meaning an event with multiple parallel calls counts as one towards the limit.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@adk-bot adk-bot added the core [Component] This issue is related to the core interface and implementation label Jan 13, 2026
Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request effectively addresses the bug where Automatic Function Calling (AFC) settings were being ignored. The introduction of the _should_stop_afc_loop helper function is a good approach to centralize the control logic, and the pre-execution checks are correctly placed in the post-processing methods. The new test suite is comprehensive and provides excellent coverage for the fix. I have a couple of suggestions to further improve code readability and maintainability.

@sajanlamsal
Copy link
Author

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request effectively addresses Bug #4133 by correctly enforcing AutomaticFunctionCallingConfig settings and introduces a robust guardrail mechanism to prevent infinite loops in Automatic Function Calling (AFC). The changes are well-structured, thoroughly tested with comprehensive unit tests for both AFC configuration and guardrail functionality, and include detailed logging for observability. The implementation demonstrates careful consideration of edge cases and maintains backward compatibility. This is a significant improvement to the stability and predictability of the AFC system.

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
@sajanlamsal sajanlamsal changed the title # Fix Bug #4133: AutomaticFunctionCallingConfig Settings Ignored fix:Bug #4133: AutomaticFunctionCallingConfig Settings Ignored Jan 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core [Component] This issue is related to the core interface and implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: AutomaticFunctionCallingConfig(disable=True) is ignored - Planner hook bypassed

2 participants