Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions FirebaseAI/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
`start` and `end`, removed `VoiceActivityType.unknown` from the public API,
and made `LiveServerVoiceActivity.audioOffset` a non-optional `TimeInterval`
(defaulting to `0`).
- [changed] `GeminiLanguageModel` now limits each prompt to 10 tool-calling
turns, including when `toolCallingMode` is `.required`. After 10 turns, the
next request asks Gemini for a final response without calling tools; if
Gemini calls a tool anyway, the session throws
`GeminiLanguageModel.Error.invalidResponse`. (#17043)
- [fixed] Fixed `GeminiLanguageModel` running a tool, or throwing a
`GeneratedContent.ParsingError`, when Gemini called a tool that wasn't enabled
for the request, including when `toolCallingMode` was `.disallowed`. It now
throws `GeminiLanguageModel.Error.invalidResponse` instead. (#17043)
- [fixed] Fixed a crash at launch on iOS 17 and earlier, and the corresponding
macOS, tvOS, watchOS, and visionOS versions, in apps built with Xcode 27 when
the app or a library (such as Realm) enumerated Objective-C classes with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
/// [`LanguageModel`](https://developer.apple.com/documentation/foundationmodels/languagemodel)
/// protocol and can be used with the Foundation Models framework when [initializing](https://developer.apple.com/documentation/foundationmodels/languagemodelsession/init%28model:tools:instructions:%29)
/// a [`LanguageModelSession`](https://developer.apple.com/documentation/foundationmodels/languagemodelsession),
/// or by [setting the model](https://developer.apple.com/documentation/foundationmodels/languagemodelsession/dynamicprofile/model%28_%29)
/// or by [setting the model](https://developer.apple.com/documentation/foundationmodels/languagemodelsession/dynamicprofile/model%28_:%29)
/// on a [`DynamicProfile`](https://developer.apple.com/documentation/foundationmodels/languagemodelsession/dynamicprofile).
///
/// For more details on using Gemini to generate content with the Foundation Models framework,
Expand Down Expand Up @@ -107,6 +107,47 @@
/// images in the session are re-sent with every request.
/// - Image files are read again for every request in the session, so a file must remain
/// available for as long as the session that references it is used.
///
/// ### Tool calling
///
/// With the `toolCalling` capability, the model can call the tools that you provide to the
/// session. To limit cost when the model keeps calling tools, the model can make at most 10
/// tool-calling turns in response to each prompt. A tool-calling turn is one response that
/// calls one or more tools.
///
/// - After 10 tool-calling turns, the next request tells Gemini not to call tools, so that
/// the model gives a final response.
/// - If Gemini calls a tool anyway, the session throws
/// ``GeminiLanguageModel/Error/invalidResponse(_:)``.
///
/// The limit also applies when the tool calling mode is `required`. With `required`, the model
/// otherwise keeps calling tools until a tool throws an error or the mode changes. To stop
/// sooner, change the mode with the [`toolCallingMode(_:)`](https://developer.apple.com/documentation/foundationmodels/languagemodelsession/dynamicprofile/toolcallingmode%28_:%29)
/// modifier of a `LanguageModelSession.DynamicProfile`, for example from `required` to
/// `allowed` after the first tool call, as shown in [`GenerationOptions.ToolCallingMode`](https://developer.apple.com/documentation/foundationmodels/generationoptions/toolcallingmode-swift.struct).
///
/// The limit is counted from the transcript that the session sends to the model, after any
/// [`historyTransform(_:)`](https://developer.apple.com/documentation/foundationmodels/languagemodelsession/dynamicprofile/historytransform%28_:%29)
/// of a `LanguageModelSession.DynamicProfile`. A transform that removes earlier tool calls can
/// stop the limit from being reached, and a transform that removes prompts can make the limit
/// apply across prompts. Each tool-calling turn adds several transcript entries, such as the
/// tool calls, their outputs, and the model's reasoning, so a transform that keeps a fixed
/// number of recent entries keeps far fewer tool-calling turns than entries.
///
/// If Gemini calls a tool when the tool calling mode is `disallowed`, or calls a tool that
/// isn't enabled for the request, the session throws
/// ``GeminiLanguageModel/Error/invalidResponse(_:)`` and the tool doesn't run.
///
/// > Warning: Gemini doesn't always follow the `disallowed` tool calling mode, especially after
/// > several tool calls in response to a prompt. Changing the mode to `disallowed` partway
/// > through a prompt can make the session throw
/// > ``GeminiLanguageModel/Error/invalidResponse(_:)`` instead of returning a response.
///
/// By default, when the session throws an error, it reverts its transcript to how it was before
/// the prompt, even if tools have already run. To keep the tool calls and their outputs, set
/// the session's [`transcriptErrorHandlingPolicy`](https://developer.apple.com/documentation/foundationmodels/languagemodelsession/transcripterrorhandlingpolicy)
/// to `preserveTranscript`, or use the [`transcriptErrorHandlingPolicy(_:)`](https://developer.apple.com/documentation/foundationmodels/languagemodelsession/dynamicprofile/transcripterrorhandlingpolicy%28_:%29)
/// modifier of a `LanguageModelSession.DynamicProfile`.
public var capabilities: LanguageModelCapabilities {
LanguageModelCapabilities([
.guidedGeneration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@
}
}

/// The maximum number of tool-calling turns allowed in response to a single prompt.
///
/// A tool-calling turn is a model response that calls one or more tools. When this limit is
/// reached, the next request tells Gemini not to call functions; if it returns a function
/// call anyway, the executor throws `GeminiLanguageModel.Error.invalidResponse`. Keep the
/// `GeminiLanguageModel.capabilities` docs and the changelog in sync with this value.
static let maximumToolCallingTurns = 10

private let configuration: Configuration

/// Initializes a new executor with the specified configuration.
Expand All @@ -88,7 +96,15 @@
model: GeminiLanguageModel,
streamingInto channel: LanguageModelExecutorGenerationChannel
) async throws {
let generateRequest = try GeminiRequestTranslator.translate(request)
// The session keeps calling tools for as long as the model returns function calls, so
// limit the number of tool-calling turns to bound the number of requests.
let isAtToolCallingTurnLimit =
GeminiRequestTranslator.toolCallingTurnCount(in: request.transcript)
>= Self.maximumToolCallingTurns
let generateRequest = try GeminiRequestTranslator.translate(
request,
isAtToolCallingTurnLimit: isAtToolCallingTurnLimit
)

let client = GeminiAPIClient(
modelResource: configuration.modelResource,
Expand Down Expand Up @@ -127,10 +143,22 @@
try Task.checkCancellation()

try GeminiErrorMapper.checkGuardrails(in: chunk)
try Self.validateFinishReason(of: chunk)

if let candidates = chunk.candidates, let candidate = candidates.first,
let parts = candidate.content?.parts
{
// Check every function call in the chunk before streaming any of the chunk's parts,
// so that a rejected function call's own parts (such as its thought signature)
// aren't streamed. Parts from earlier chunks have already been streamed: when this
// throws, the session discards their tool calls, but with the `preserveTranscript`
// policy it keeps their text and reasoning.
try Self.validateFunctionCalls(
in: parts,
for: request,
isAtToolCallingTurnLimit: isAtToolCallingTurnLimit
)

for part in parts {
if let thoughtSignature = part.thoughtSignature, !thoughtSignature.isEmpty {
let signatureData = Data(thoughtSignature.utf8)
Expand Down Expand Up @@ -167,13 +195,9 @@
}

if case .functionCall(let call) = part.data {
guard let name = call.name else {
throw GeminiLanguageModel.Error.invalidResponse(
GeminiLanguageModel.Error.InvalidResponse(
debugDescription: "Gemini returned a function call without a name."
)
)
}
// `validateFunctionCalls` has already checked that every function call in this
// chunk has a name and is allowed for this request, so this doesn't throw.
let name = try Self.functionName(of: call)
let callID = call.id ?? UUID().uuidString
let argsString: String
if let args = call.args, !args.isEmpty {
Expand Down Expand Up @@ -232,6 +256,117 @@
throw GeminiErrorMapper.map(error)
}
}

/// Checks that a response chunk didn't stop because of a function call that Gemini blocked.
///
/// Instead of returning a function call part, Gemini can stop a response with the
/// `UNEXPECTED_TOOL_CALL` finish reason (for example, when it tries to call a function while
/// function calling is disabled) or the `MALFORMED_FUNCTION_CALL` finish reason, typically
/// with no content.
///
/// - Parameter chunk: A response chunk from Gemini.
/// - Throws: `GeminiLanguageModel.Error.invalidResponse` if the chunk's finish reason is
/// `UNEXPECTED_TOOL_CALL` or `MALFORMED_FUNCTION_CALL`.
static func validateFinishReason(of chunk: GenerateContentResponse) throws {
guard let finishReason = chunk.candidates?.first?.finishReason,
finishReason == .unexpectedToolCall || finishReason == .malformedFunctionCall
else {
return
}
throw GeminiLanguageModel.Error.invalidResponse(
GeminiLanguageModel.Error.InvalidResponse(
debugDescription: """
Gemini stopped with the `\(finishReason.rawValue)` finish reason instead of \
returning a valid function call.
"""
)
)
}

/// Checks that the function calls in a response can be passed to the session.
///
/// - Parameters:
/// - parts: The parts of a response chunk from Gemini.
/// - request: The generation request from the Foundation Models session.
/// - isAtToolCallingTurnLimit: Whether the session has reached the maximum number of
/// tool-calling turns for the current prompt.
/// - Throws: `GeminiLanguageModel.Error.invalidResponse` if a function call has no name or
/// if the request doesn't allow it (see `functionCallRejectionReason`).
static func validateFunctionCalls(
in parts: [Part],
for request: LanguageModelExecutorGenerationRequest,
isAtToolCallingTurnLimit: Bool
) throws {
for part in parts {
guard case .functionCall(let call) = part.data else { continue }
let name = try functionName(of: call)
if let reason = functionCallRejectionReason(
forFunctionNamed: name,
in: request,
isAtToolCallingTurnLimit: isAtToolCallingTurnLimit
) {
throw GeminiLanguageModel.Error.invalidResponse(
GeminiLanguageModel.Error.InvalidResponse(debugDescription: reason)
)
}
}
}

/// Returns the name of the function that a function call from Gemini calls.
///
/// - Parameter call: A function call from Gemini.
/// - Returns: The non-empty name of the function.
/// - Throws: `GeminiLanguageModel.Error.invalidResponse` if the function call has no name or
/// an empty name.
static func functionName(of call: FunctionCall) throws -> String {
guard let name = call.name, !name.isEmpty else {
throw GeminiLanguageModel.Error.invalidResponse(
GeminiLanguageModel.Error.InvalidResponse(
debugDescription: "Gemini returned a function call without a name."
)
)
}
return name
}

/// Returns the reason to reject a function call from Gemini, or `nil` if the session can run
/// it.
///
/// `LanguageModelSession` runs any tool call that names one of the session's tools, even
/// when the tool calling mode is `disallowed` or the tool isn't enabled for the request.
/// Gemini sometimes returns a function call when it's told not to call functions, or for a
/// function that the request doesn't declare, so the executor only passes on function calls
/// to the request's enabled tools while function calling is enabled.
///
/// - Parameters:
/// - name: The name of the function that Gemini called.
/// - request: The generation request from the Foundation Models session.
/// - isAtToolCallingTurnLimit: Whether the session has reached the maximum number of
/// tool-calling turns for the current prompt.
/// - Returns: The reason to reject the function call, or `nil` if the session can run it.
static func functionCallRejectionReason(
forFunctionNamed name: String,
in request: LanguageModelExecutorGenerationRequest,
isAtToolCallingTurnLimit: Bool
) -> String? {
if request.generationOptions.toolCallingMode?.kind == .disallowed {
return """
Gemini returned a function call to `\(name)` when the tool calling mode was \
`disallowed`.
"""
}
if isAtToolCallingTurnLimit {
return """
Gemini returned a function call to `\(name)` after function calling was disabled \
because the limit of \(maximumToolCallingTurns) tool-calling turns per prompt was \
reached.
"""
}
if !request.enabledToolDefinitions.contains(where: { $0.name == name }) {
return "Gemini returned a function call to `\(name)`, which isn't an enabled tool."
}
return nil
}
}
}
#endif // compiler(>=6.4) && (FoundationModelsFake || canImport(FoundationModels))
Loading
Loading