Repository navigation
feat(ai): limit GLM tool-calling turns to 10 - #17043
Conversation
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
Using Gemini Code AssistThe 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
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 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.
Summary of ChangesThis 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 Highlights
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
|
There was a problem hiding this comment.
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.
paulb777
left a comment
There was a problem hiding this comment.
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.
A
LanguageModelSessionkeeps calling tools for as long as the model returns function calls. WithtoolCallingMode: .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).
NONEso that Gemini gives a final response.GeminiLanguageModel.Error.invalidResponseif Gemini returns a function call anyway, instead of passing the call to the session, since Gemini doesn't always followNONE. It also throwsinvalidResponsewhen Gemini stops with theUNEXPECTED_TOOL_CALLorMALFORMED_FUNCTION_CALLfinish reason, which previously ended the response with no content.disallowed, when no tools are enabled, or when Gemini calls a tool that isn't enabled for the request.LanguageModelSessionruns every tool call that names one of its tools, even with.disallowed, so previously a stray function call from Gemini would run the tool.NONE, so that the history doesn't refer to undeclared functions.GeminiLanguageModel.capabilitiesand in the changelog.The limit isn't configurable yet, since that will need a public API review. Internally, it's the
GeminiLanguageModel.Executor.maximumToolCallingTurnsconstant.Behavior details
Transcript.Entry.toolCallsentry, so parallel calls in one response count as one turn. EmptytoolCallsentries don't count.Transcript.Entry.prompt, so eachrespond(to:)call gets its own 10 turns. The executor doesn't store any state.NONE.NONEwhatever the tool calling mode is, including.required, which otherwise never stops on its own.toolConfig.functionCallingConfig.modechanges..disallowed, or aDynamicProfilethat 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, withNONE. These declarations are best effort: a tool whose schema can't be translated is skipped instead of failing the request.LanguageModelSessionruns 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 "LanguageModelSessiontool-calling behavior" below.transcriptErrorHandlingPolicyto.preserveTranscript, either on the session or from aDynamicProfile. The next prompt still starts a new count.LanguageModelSession.DynamicProfile, for example after the first tool call. This is documented oncapabilities.DynamicProfile.historyTransform(_:). I confirmed this with the real framework: a transform that keeps only the most recent entries (for exampleArray($0.suffix(3))) stops the limit from being reached, and a transform that removes prompts counts turns across prompts. This is documented oncapabilities. 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 perrequest.idinstead, which is the same for every request in onerespond(to:)call and changes with each prompt, but the executor would need shared state.LanguageModelSessiontool-calling behaviorTested with a scripted
LanguageModelExecutorand agetWeathertool against the real FoundationModels framework (macOS 27.2, Xcode 27.3):toolCalls+toolOutput, returns the texttoolCallingModeis.disallowedremoveToolCall(id:)LanguageModelError("The model produced an empty response.")ParsingError("Model generated a tool call with an unrecognized name.")session.transcriptsession.transcriptErrorHandlingPolicy = .preserveTranscriptsession.transcriptThis means:
NONEisn'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
mainbranch on 2026-11-10.Firebase AI Logic SDKs
NONE; throwsinvalidResponseif Gemini still calls a toolRequestOptions(autoFunctionCallingTurnLimit:)RequestTimeoutException("Request took too many turns")RequestOptions.maxSequentialFunctionCallsstartChat(maxTurns:)Exception("Max turns of 5 reached."); the results of the last tool calls are never sentRequestOptions(autoFunctionTurnLimit:)Google GenAI SDKs
The GenAI SDKs count model requests ("remote calls"), not tool-calling turns.
automatic_function_calling.maximum_remote_callsAutomaticFunctionCallingConfig.maximumRemoteCallsautomaticFunctionCalling.maximumRemoteCallsUseFunctionInvocation(), which has its own limitTakeaways
NONE) instead of stopping on a function call. See "Why not return the last function call" above.RequestOptions.Testing
Unit tests (
GeminiLanguageModelTests):sessionRespondDisablesFunctionCallingAtToolCallingTurnLimit: with.required, the first 10 requests use modeANY, and the 11th usesNONEand still includes the function declarations. The session returns the text response.functionCallAfterToolCallingTurnLimitThrowsInvalidResponse: when the model always returns a function call, the session throwsinvalidResponseafter 11 requests.unexpectedToolCallAfterToolCallingTurnLimitThrowsInvalidResponse: anUNEXPECTED_TOOL_CALLfinish reason on the 11th request throwsinvalidResponse.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.functionCallWithToolCallingModeDisallowedThrowsInvalidResponse,functionCallToUnknownToolThrowsInvalidResponse,functionCallWithoutToolsThrowsInvalidResponse, andfunctionCallWithEmptyNameThrowsInvalidResponse.functionCallFinishReasonThrowsInvalidResponse:UNEXPECTED_TOOL_CALLandMALFORMED_FUNCTION_CALLfinish reasons throwinvalidResponse.functionCallRejectionReason: a parameterized table of the rejection precedence (disallowed, then the turn limit, then tools that aren't enabled).streamResponseDoesNotStreamPartsOfChunkWithRejectedFunctionCall,rejectedFunctionCallDoesNotStreamAllowedCallOrTextInSameChunk, andrejectedFunctionCallInLaterChunkKeepsNoEarlierToolCalls: nothing from a rejected chunk, or earlier tool calls, is kept, even with.preserveTranscript.disallowedRequestAfterToolCallsDeclaresToolsWithFunctionCallingNone: a.disallowedprompt after earlier tool calls declares the session's tools withNONE.Unit tests (
GeminiRequestTranslatorTests):toolCallingTurnCountCountsToolCallsSinceMostRecentPromptandtoolCallingTurnCountInEmptyTranscriptIsZero: parallel calls count as one turn, emptytoolCallsentries 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) becomesNONE.translatesRequestAtToolCallingTurnLimitKeepsToolDeclarations: function declarations are still sent at the limit.translatesRequestWithoutEnabledToolsAfterToolCallsDeclaresInstructionsToolsWithNone(fornil,.allowed, and.required),translatesDisallowedRequestAfterToolCallsDeclaresInstructionsToolsWithNone,translatesRequestWithoutEnabledToolsAtToolCallingTurnLimitDeclaresInstructionsTools,translatesRequestWithoutEnabledToolsDeclaresMostRecentInstructionsTools,translatesRequestWithoutEnabledToolsSkipsInstructionsWithoutTools, andtranslatesDisallowedRequestWithoutToolCallsOmitsTools.translatesDisallowedRequestDeclaresOnlyTranslatableInstructionsToolsandtranslatesDisallowedRequestWithUntranslatableInstructionsToolOmitsTools, whiletranslatesRequestWithUntranslatableEnabledToolThrowschecks that enabled tools still fail on an untranslatable schema.Integration test (
ToolCallingIntegrationTests):sessionRespondRequiredToolCallingStopsAtToolCallingTurnLimit: on every available backend,.requiredagainst live Gemini calls the tool exactly 10 times. The test accepts either a text response orinvalidResponse, because Gemini sometimes ignoresNONE.