Skip to content

feat(ai): limit GLM tool-calling turns to 10 - #17043

Merged
andrewheard merged 10 commits into
mainfrom
ah/ai-glm-max-tool-calling-turns
Nov 12, 2026
Merged

andrewheard merged 10 commits into
mainfrom
ah/ai-glm-max-tool-calling-turns

Conversation

@andrewheard

@andrewheard andrewheard commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

A LanguageModelSession keeps calling tools for as long as the model returns function calls. With toolCallingMode: .required, or when tools are combined with a response schema (guided generation), gemini-3.5-flash-lite (and likely other models) sometimes repeats the same function call, so the number of requests is unbounded.

This PR limits each prompt to 10 tool-calling turns, the same default used by the Firebase AI Logic Android and Web SDKs (see the comparison below).

  • Counted the tool-calling turns since the most recent prompt. After 10, the next request is sent with function calling mode NONE so that Gemini gives a final response.
  • Made the executor throw GeminiLanguageModel.Error.invalidResponse if Gemini returns a function call anyway, instead of passing the call to the session, since Gemini doesn't always follow NONE. It also throws invalidResponse when Gemini stops with the UNEXPECTED_TOOL_CALL or MALFORMED_FUNCTION_CALL finish reason, which previously ended the response with no content.
  • Applied the same check when the tool calling mode is disallowed, when no tools are enabled, or when Gemini calls a tool that isn't enabled for the request. LanguageModelSession runs every tool call that names one of its tools, even with .disallowed, so previously a stray function call from Gemini would run the tool.
  • When a request enables no tools but the transcript already has tool calls, declared the tools from the transcript's latest instructions with mode NONE, so that the history doesn't refer to undeclared functions.
  • Documented the limit on GeminiLanguageModel.capabilities and in the changelog.

The limit isn't configurable yet, since that will need a public API review. Internally, it's the GeminiLanguageModel.Executor.maximumToolCallingTurns constant.

Behavior details
  • What counts as a turn: one model response that calls one or more tools. The session records each response as a single Transcript.Entry.toolCalls entry, so parallel calls in one response count as one turn. Empty toolCalls entries don't count.
  • Per prompt: the count comes from the transcript and resets at each Transcript.Entry.prompt, so each respond(to:) call gets its own 10 turns. The executor doesn't store any state.
  • Request count: at most 11 requests per prompt: 10 that can call tools, plus one final request with NONE.
  • All tool calling modes: the final request uses NONE whatever the tool calling mode is, including .required, which otherwise never stops on its own.
  • Tool declarations are kept: the final request still includes the function declarations, because the history contains function calls and responses that refer to them. Only toolConfig.functionCallingConfig.mode changes.
  • No enabled tools: a request that enables no tools (for example with .disallowed, or a DynamicProfile that disables every tool) has function calling disabled whatever the mode is. If the transcript has tool calls, the request declares the tools from the most recent instructions entry that lists tools, with NONE. These declarations are best effort: a tool whose schema can't be translated is skipped instead of failing the request.
  • Rejected function calls: the executor checks every function call in a response chunk before streaming any of the chunk's parts, so none of a rejected chunk (text, thought signatures, or other calls) reaches the transcript. Tool calls from earlier chunks are discarded by the session when the executor throws.
  • Why not return the last function call: the Web, Unity, and GenAI SDKs stop by returning the model's last response with its function calls not run. That isn't possible here, because LanguageModelSession runs every tool call that the executor sends it, and an executor can't end a turn on a tool call that hasn't run. So the executor asks for a final response and throws if it doesn't get one. See "LanguageModelSession tool-calling behavior" below.
  • Transcript after the limit error: by default, when the executor throws, the session reverts its transcript to how it was before the prompt. The prompt, tool calls, and tool outputs are removed even though the tools already ran, which makes it easy to retry the prompt. Apps that want to keep the tool calls can set transcriptErrorHandlingPolicy to .preserveTranscript, either on the session or from a DynamicProfile. The next prompt still starts a new count.
  • Stopping sooner: apps can change the tool calling mode from a LanguageModelSession.DynamicProfile, for example after the first tool call. This is documented on capabilities.
  • History transforms (known limitation): the turn count comes from the transcript in each executor request, which is the transcript after any DynamicProfile.historyTransform(_:). I confirmed this with the real framework: a transform that keeps only the most recent entries (for example Array($0.suffix(3))) stops the limit from being reached, and a transform that removes prompts counts turns across prompts. This is documented on capabilities. Until the limit is configurable, trimming earlier tool calls also works as a stop-gap for apps that need more than 10 turns. A follow-up could count turns per request.id instead, which is the same for every request in one respond(to:) call and changes with each prompt, but the executor would need shared state.
LanguageModelSession tool-calling behavior

Tested with a scripted LanguageModelExecutor and a getWeather tool against the real FoundationModels framework (macOS 27.2, Xcode 27.3):

Executor sends Session behavior
A tool call, then text on the next request Runs the tool, sends a 2nd request with toolCalls + toolOutput, returns the text
A tool call on every request Runs the tool and requests again with no limit of its own (25 tool runs until the executor threw)
Text and a tool call in one response Still runs the tool and requests again; the text isn't returned as the final response
A tool call while toolCallingMode is .disallowed Still runs the tool. The request had no tool definitions, but the session didn't reject the call
A tool call, then removeToolCall(id:) Throws LanguageModelError ("The model produced an empty response.")
A tool call for a tool that isn't enabled Throws a ParsingError ("Model generated a tool call with an unrecognized name.")
An error after some tool calls Rethrows it. By default, removes the prompt, tool calls, and outputs from session.transcript
An error after some tool calls, with session.transcriptErrorHandlingPolicy = .preserveTranscript Rethrows it and keeps the prompt, tool calls, and outputs in session.transcript

This means:

  • The session has no limit of its own, so the executor has to provide one.
  • None of the channel events let an executor end a turn on a tool call that hasn't run. Removing the call or naming an unknown tool throws.
  • Setting the mode to NONE isn't enough: if Gemini returns a function call anyway and the executor passes it on, the session runs the tool, even when the mode is .disallowed. That's why the executor throws instead.
Comparison with other SDKs

Checked against each repository's main branch on 2026-11-10.

Firebase AI Logic SDKs

SDK Default Setting Tool-calling turns → requests At the limit
iOS (this PR) 10 Not public yet 10 → 11 Final request uses mode NONE; throws invalidResponse if Gemini still calls a tool
Android 10 RequestOptions(autoFunctionCallingTurnLimit:) 10 → 11 Throws RequestTimeoutException ("Request took too many turns")
Web 10 RequestOptions.maxSequentialFunctionCalls 10 → 11 Logs a warning and returns the last response with its function calls not run
Flutter 5 startChat(maxTurns:) 5 → 5 Throws Exception ("Max turns of 5 reached."); the results of the last tool calls are never sent
Unity 5 RequestOptions(autoFunctionTurnLimit:) 5 → 6 Returns the last response with its function calls not run

Google GenAI SDKs

The GenAI SDKs count model requests ("remote calls"), not tool-calling turns.

SDK Default Setting Tool-calling turns → requests At the limit
Python 10 automatic_function_calling.maximum_remote_calls 9 → 10 Returns the last response with its function calls not run
Java 10 AutomaticFunctionCallingConfig.maximumRemoteCalls 9 → 10 Returns the last response with its function calls not run
JS/TS 10 automaticFunctionCalling.maximumRemoteCalls 10 → 10 Returns the last response; its function calls are run, but the results are never sent
Go — — — No automatic function calling
.NET — — — Uses Microsoft.Extensions.AI's UseFunctionInvocation(), which has its own limit

Takeaways

  • 10 matches Android and Web exactly (10 tool-calling turns, 11 requests), and matches the GenAI SDKs to within one request.
  • iOS is the only SDK that asks the model for a final response (mode NONE) instead of stopping on a function call. See "Why not return the last function call" above.
  • If the limit becomes configurable, Android and Web both expose it on RequestOptions.
  • Flutter and Unity default to 5, which differs from the other Firebase SDKs.
Testing

Unit tests (GeminiLanguageModelTests):

  • Turn limit:
    • sessionRespondDisablesFunctionCallingAtToolCallingTurnLimit: with .required, the first 10 requests use mode ANY, and the 11th uses NONE and still includes the function declarations. The session returns the text response.
    • functionCallAfterToolCallingTurnLimitThrowsInvalidResponse: when the model always returns a function call, the session throws invalidResponse after 11 requests.
    • unexpectedToolCallAfterToolCallingTurnLimitThrowsInvalidResponse: an UNEXPECTED_TOOL_CALL finish reason on the 11th request throws invalidResponse.
    • newPromptResetsToolCallingTurnCount: after a prompt reaches the limit, the next prompt starts a new count and uses the normal mode again.
    • parallelFunctionCallsCountAsOneToolCallingTurn: a response with two function calls counts as one turn.
    • promptAfterToolCallingTurnLimitErrorWithPreservedTranscriptSucceeds: with .preserveTranscript, a new prompt after the limit error succeeds and joins the trailing tool output's user turn.
  • Rejected function calls:
    • functionCallWithToolCallingModeDisallowedThrowsInvalidResponse, functionCallToUnknownToolThrowsInvalidResponse, functionCallWithoutToolsThrowsInvalidResponse, and functionCallWithEmptyNameThrowsInvalidResponse.
    • functionCallFinishReasonThrowsInvalidResponse: UNEXPECTED_TOOL_CALL and MALFORMED_FUNCTION_CALL finish reasons throw invalidResponse.
    • functionCallRejectionReason: a parameterized table of the rejection precedence (disallowed, then the turn limit, then tools that aren't enabled).
  • Streaming:
    • streamResponseDoesNotStreamPartsOfChunkWithRejectedFunctionCall, rejectedFunctionCallDoesNotStreamAllowedCallOrTextInSameChunk, and rejectedFunctionCallInLaterChunkKeepsNoEarlierToolCalls: nothing from a rejected chunk, or earlier tool calls, is kept, even with .preserveTranscript.
  • disallowedRequestAfterToolCallsDeclaresToolsWithFunctionCallingNone: a .disallowed prompt after earlier tool calls declares the session's tools with NONE.

Unit tests (GeminiRequestTranslatorTests):

  • toolCallingTurnCountCountsToolCallsSinceMostRecentPrompt and toolCallingTurnCountInEmptyTranscriptIsZero: parallel calls count as one turn, empty toolCalls entries are ignored, the count resets at each prompt, and an empty transcript has 0 turns.
  • translatesToolConfigWithFunctionCallingDisabledToNone: with function calling disabled, every tool calling mode (nil, .allowed, .required, .disallowed) becomes NONE.
  • translatesRequestAtToolCallingTurnLimitKeepsToolDeclarations: function declarations are still sent at the limit.
  • Requests without enabled tools: translatesRequestWithoutEnabledToolsAfterToolCallsDeclaresInstructionsToolsWithNone (for nil, .allowed, and .required), translatesDisallowedRequestAfterToolCallsDeclaresInstructionsToolsWithNone, translatesRequestWithoutEnabledToolsAtToolCallingTurnLimitDeclaresInstructionsTools, translatesRequestWithoutEnabledToolsDeclaresMostRecentInstructionsTools, translatesRequestWithoutEnabledToolsSkipsInstructionsWithoutTools, and translatesDisallowedRequestWithoutToolCallsOmitsTools.
  • Best-effort declarations: translatesDisallowedRequestDeclaresOnlyTranslatableInstructionsTools and translatesDisallowedRequestWithUntranslatableInstructionsToolOmitsTools, while translatesRequestWithUntranslatableEnabledToolThrows checks that enabled tools still fail on an untranslatable schema.

Integration test (ToolCallingIntegrationTests):

  • sessionRespondRequiredToolCallingStopsAtToolCallingTurnLimit: on every available backend, .required against live Gemini calls the tool exactly 10 times. The test accepts either a text response or invalidResponse, because Gemini sometimes ignores NONE.

A LanguageModelSession keeps calling tools for as long as the model
returns function calls. With `toolCallingMode: .required`, or with a
response schema, gemini-3.5-flash-lite can keep calling the same tool
indefinitely, which runs up charges and hangs CI.

- Count the tool-calling turns since the most recent prompt. After 10,
  send the next request with function calling mode `NONE`.
- Gemini doesn't always follow `NONE`, so if it returns a function call
  anyway, throw `GeminiLanguageModel.Error.invalidResponse` instead of
  passing the call to the session.
- Document the limit on `GeminiLanguageModel.capabilities` and in the
  changelog.

The limit isn't configurable yet, since that needs a public API review.

# Conflicts:
#	FirebaseAI/CHANGELOG.md
@gemini-code-assist

Copy link
Copy Markdown
Contributor
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 the 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 counterproductive. 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.

CountingWeatherTool conformed to FoundationModels.Tool, which doesn't
exist when building with the FoundationModelsFake trait (the default on
Linux), so the GeminiLanguageModelTests target failed to compile in the
unit-tests-linux and unit-tests-macos-fake jobs. Use the
FoundationModelsTool typealias, like the other test tools.
LanguageModelSession runs every tool call that the executor sends it,
even when the tool calling mode is `disallowed` (tested on macOS 27.2).
Gemini sometimes returns a function call anyway, including for tools
that the request doesn't declare, so the session could run a tool that
the app disallowed.

Throw `GeminiLanguageModel.Error.invalidResponse` instead of passing a
function call to the session when the tool calling mode is `disallowed`,
when no tools are enabled, or when the tool-calling turn limit is
reached, with a message for each case.

Also document that the turn limit is counted from the transcript that
the session sends to the model, so a `historyTransform(_:)` that removes
earlier tool calls or prompts changes the count.
LanguageModelSession runs any tool call that names one of the session's
tools, without checking the tools enabled for the request. Check each
function call against the request's enabled tools, in addition to the
`disallowed` mode and the tool-calling turn limit, so that a call to a
tool that isn't enabled throws `GeminiLanguageModel.Error.invalidResponse`
instead of running the tool or throwing `GeneratedContent.ParsingError`.
Validate every function call in a chunk before streaming any of its
parts, so that a rejected call doesn't leave a partial response behind.

With the `disallowed` mode, the session enables no tools, so a request
after earlier tool calls sent function calls to undeclared functions.
Declare the tools from the transcript's instructions with function
calling mode `NONE` in that case, matching the final request at the
turn limit.

Also:
- Validate an unknown tool calling mode before applying the turn limit.
- Rename `toolCallingTurns(in:)` to `toolCallingTurnCount(in:)`, count
  from the end of the transcript, and remove the `= false` defaults.
- Document the transcript reversion on errors, recommend switching from
  `required` to `allowed`, and clarify the `historyTransform` caveat.
- Add PR numbers to the changelog entries.
- Strengthen the tests: assert tool runs from the transcript, prove the
  turn count resets, cover parallel calls and the rejection order, add a
  time limit to the suite, and count tool-calling turns rather than tool
  calls in the integration test.
Replace the advice to prefer `allowed` with a warning that changing the
tool calling mode to `disallowed` partway through a prompt can end the
prompt with `GeminiLanguageModel.Error.invalidResponse`, and reword the
summary of `functionCallRejectionReason`.
Declaring the transcript's instructions tools when function calling is
disabled is best effort, so send the request without declarations if a
tool's schema can't be translated, instead of failing a request that
previously succeeded. Document that the fallback also applies at the
turn limit and that it uses the latest instructions' tools.

Also:
- Reject function calls with an empty name like those without a name.
- Use `isFunctionCallingDisabled` to choose the `NONE` mode.
- Clarify which parts the pre-streaming check keeps out of the
  transcript, and assert that every function call was checked.
- Reorder the tool calling docs, name the error in the `disallowed`
  warning, and mention the `transcriptErrorHandlingPolicy(_:)` modifier.
- Test that a rejected function call's chunk isn't streamed, the
  fallback at the turn limit and with an untranslatable schema, and
  empty function names; rename the unknown-tool test.
- Mention the turn limit in the test READMEs.
A request that enables no tools, such as one from a DynamicProfile that
disables every tool, sent earlier function calls without declarations
or a tool config unless the tool calling mode was `disallowed`. Treat
any request without enabled tools as having function calling disabled,
whatever the mode, and declare the latest instructions' tools with mode
`NONE` when the transcript has tool calls.

Translate these fallback declarations per tool, so that one tool whose
schema can't be translated no longer drops the other declarations and
the `NONE` mode with them.

Also:
- Remove the unused configurable tool-calling turn limit and its
  precondition; the limit is now `Executor.maximumToolCallingTurns`.
- Replace an `assertionFailure` in the streaming loop with a throwing
  `functionName(of:)` helper, so that the check can't trap.
- Fix Apple documentation links that were missing the `:` in `(_:)`.
- Say "isn't enabled for the request" in the tool calling docs, note
  that each tool-calling turn adds several transcript entries, and say
  which instructions entry the fallback declarations come from.
- Test a prompt after a preserved turn-limit error, rejected calls in
  the same and a later chunk, requests without enabled tools in each
  mode, multiple instructions entries, and partially translatable
  tools.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

This pull request introduces a safety mechanism to bound the number of tool-calling turns in a single prompt to 10. This prevents infinite loops where models might repeatedly call tools, which is particularly important when using configurations like toolCallingMode: .required. The changes ensure that the session gracefully transitions to a final response state after the limit is reached and provides robust error handling if the model fails to comply with the restricted tool-calling configuration.

Highlights

  • Tool-calling turn limit: Implemented a limit of 10 tool-calling turns per prompt to prevent unbounded request loops, matching the behavior of other Firebase AI SDKs.
  • Enforced termination: After 10 turns, the executor now forces the model to stop calling tools by setting the function calling mode to NONE for the final request.
  • Error handling: The executor now throws GeminiLanguageModel.Error.invalidResponse if the model continues to return function calls after the limit is reached or when function calling is disallowed.
  • Documentation: Updated GeminiLanguageModel.capabilities and the changelog to document the new tool-calling turn limit and associated behaviors.
New Features

🧠 You can now enable Memory (public preview) 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.

Activity
  • The pull request was created by andrewheard.
  • A bot comment provided instructions on using Gemini Code Assist.
  • The author requested a summary of the pull request.

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

Copy link
Copy Markdown
Contributor

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 introduces a limit of 10 tool-calling turns per prompt in GeminiLanguageModel to prevent runaway tool-calling loops and control costs. It implements validation logic to reject unauthorized or excessive function calls, throwing a GeminiLanguageModel.Error.invalidResponse error when limits are exceeded or when tools are called in disallowed modes. Additionally, comprehensive unit and integration tests have been added to verify these behaviors under various scenarios. As there are no review comments, I have no further feedback to provide.

@andrewheard
andrewheard marked this pull request as ready for review November 11, 2026 21:16
@andrewheard
andrewheard requested a review from paulb777 November 11, 2026 21:16

@paulb777 paulb777 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please do a quick read of the doc I shared with you

With function calling disabled, Gemini can block its own function call
and stop with the `UNEXPECTED_TOOL_CALL` finish reason (or stop with
`MALFORMED_FUNCTION_CALL`), typically with no content, instead of
returning a function call part. The executor only checked function call
parts, so the session ended with an empty response. Throw
`GeminiLanguageModel.Error.invalidResponse` for these finish reasons,
as for a function call when function calling is disabled.

Also use the `NONE` mode without checking the requested tool calling
mode when function calling is disabled, so that a tool calling mode
from a later OS release no longer throws `unsupportedCapability` for a
request without enabled tools after earlier tool calls.
@andrewheard andrewheard added this to the 13.2.0 - M189 milestone Nov 12, 2026
@andrewheard
andrewheard merged commit efad2b7 into main Nov 12, 2026
71 checks passed
@andrewheard
andrewheard deleted the ah/ai-glm-max-tool-calling-turns branch November 12, 2026 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants