Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.adk.kt.agents

import com.google.adk.kt.annotations.ExperimentalContextCachingFeature
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

/**
* Configuration for context caching across all agents in an app.
*
* When this config is present on an app, context caching is enabled for all agents. When absent
* (`null`), context caching is disabled.
*
* Context caching can significantly reduce costs and improve response times by reusing previously
* processed context across multiple requests.
*
* Constructing this type requires `@OptIn(ExperimentalContextCachingFeature::class)`.
*
* @property maxInvocations Maximum number of invocations to reuse the same cache before refreshing
* it. Must be in the range `1..100`. Defaults to 10.
* @property ttl Time-to-live for the cache. Must be strictly positive. Defaults to 30 minutes.
* @property minTokens Minimum prior-request tokens required to enable caching. This gates on the
* previous request's actual prompt token count (from the model's usage metadata), not an estimate
* of the current request. Gemini enforces a hard 4096-token minimum that always applies, so
* values below 4096 have no additional effect. No cache is created on the first request of a
* session; caching begins on the second turn once a previous token count is known. Context cache
* storage may have a cost, so set this higher to avoid caching small requests where the overhead
* may exceed the benefits. Must be non-negative. Defaults to 0.
*/
data class ContextCacheConfig
@ExperimentalContextCachingFeature
constructor(
val maxInvocations: Int = 10,
val ttl: Duration = 1800.seconds,
val minTokens: Int = 0,
) {
init {
require(maxInvocations in 1..100) {
"maxInvocations must be in 1..100, but was $maxInvocations."
}
require(ttl.isPositive()) { "ttl must be positive, but was $ttl." }
require(minTokens >= 0) { "minTokens must be >= 0, but was $minTokens." }
}

/** Returns the TTL in the string format used for cache creation, e.g. `"1800s"`. */
val ttlString: String
get() = "${ttl.inWholeSeconds}s"
}
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ data class InvocationContext(
*/
val eventsCompactionConfig: EventsCompactionConfig? = null,

/**
* Optional context cache configuration for this invocation, propagated from the [App]. When
* `null`, context caching is disabled for the invocation.
*/
val contextCacheConfig: ContextCacheConfig? = null,

// State
/** The user content that started this invocation. Readonly. */
val userContent: Content? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ internal class LlmAgentTurn(
errorMessage = response.errorMessage,
partial = response.partial,
interrupted = response.interrupted,
cacheMetadata = response.cacheMetadata,
)
.populateClientFunctionCallId()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.adk.kt.annotations

/**
* Marks ADK's **context caching** APIs as experimental: their shape and semantics may change in
* future releases without prior notice.
*
* Opt in explicitly with `@OptIn(ExperimentalContextCachingFeature::class)` to use them.
*/
@MustBeDocumented
@Retention(AnnotationRetention.BINARY)
@RequiresOptIn(
level = RequiresOptIn.Level.ERROR,
message =
"ADK context caching is an experimental feature whose API may change at any time. " +
"Opt in with @OptIn(ExperimentalContextCachingFeature::class) to acknowledge the risk.",
)
annotation class ExperimentalContextCachingFeature
4 changes: 4 additions & 0 deletions core/src/commonMain/kotlin/com/google/adk/kt/events/Event.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.google.adk.kt.events

import com.google.adk.kt.ids.Uuid
import com.google.adk.kt.models.CacheMetadata
import com.google.adk.kt.tools.BaseTool
import com.google.adk.kt.types.CitationMetadata
import com.google.adk.kt.types.Content
Expand Down Expand Up @@ -53,6 +54,8 @@ import kotlinx.serialization.Serializable
* multiple sub-agents shouldn't see their peer agents' conversation history.
* @property groundingMetadata The grounding metadata of the event.
* @property modelVersion The model version used to generate the response.
* @property cacheMetadata Context cache metadata associated with this event's LLM response, used to
* carry cache state across turns. `null` when context caching is disabled.
* @property timestamp The timestamp of the event.
*/
@Serializable
Expand All @@ -75,6 +78,7 @@ data class Event(
val groundingMetadata: GroundingMetadata? = null,
val modelVersion: String? = null,
val citationMetadata: CitationMetadata? = null,
val cacheMetadata: CacheMetadata? = null,
val customMetadata: Map<String, @Contextual Any>? = null,
val timestamp: Long = Clock.System.now().toEpochMilliseconds(),
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.adk.kt.models

import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
import kotlinx.serialization.Serializable

/**
* Metadata for the context cache associated with LLM responses.
*
* This stores cache identification, usage tracking, and lifecycle information for a particular
* cache instance. It can be in one of two states:
* 1. **Active cache state:** [cacheName] is set and [expireTime], [invocationsUsed], and
* [createdAt] are populated.
* 2. **Fingerprint-only state:** [cacheName] is `null` and only [fingerprint] and [contentsCount]
* are set, used for prefix matching before a cache exists.
*
* Token counts (cached and total) are available in [LlmResponse.usageMetadata] and should be
* accessed from there to avoid duplication.
*
* @property fingerprint Hash of the cacheable contents (system instruction + tools + contents).
* Always present for prefix matching.
* @property contentsCount Number of contents. When an active cache exists this is the count of
* cached contents; otherwise it is the count of the cacheable content prefix used for
* fingerprinting. Must be non-negative.
* @property cacheName Full resource name of the cached content (e.g.
* `projects/123/locations/us-central1/cachedContents/456`). `null` when no active cache exists.
* @property expireTime Epoch milliseconds when the cache expires. `null` when no active cache
* exists.
* @property invocationsUsed Number of invocations this cache has been used for. `null` when no
* active cache exists. Must be non-negative when set.
* @property createdAt Epoch milliseconds when the cache was created. `null` when no active cache
* exists.
*/
@Serializable
data class CacheMetadata(
val fingerprint: String,
val contentsCount: Int,
val cacheName: String? = null,
val expireTime: Long? = null,
val invocationsUsed: Int? = null,
val createdAt: Long? = null,
) {
init {
require(contentsCount >= 0) { "contentsCount must be >= 0, but was $contentsCount." }
invocationsUsed?.let { require(it >= 0) { "invocationsUsed must be >= 0, but was $it." } }
val activeFlags = listOf(cacheName != null, expireTime != null, invocationsUsed != null)
require(activeFlags.all { it } || activeFlags.none { it }) {
"cacheName, expireTime, and invocationsUsed must all be set (active cache) or all be null " +
"(fingerprint-only state)."
}
}

/** Whether this metadata refers to an active cache, as opposed to a fingerprint-only state. */
val isActive: Boolean
get() = cacheName != null

/**
* Whether the cache will expire within [EXPIRY_BUFFER]. Always `false` for fingerprint-only
* metadata (which has no [expireTime]).
*/
val expireSoon: Boolean
get() {
val expiry = expireTime ?: return false
return Clock.System.now().toEpochMilliseconds() > expiry - EXPIRY_BUFFER.inWholeMilliseconds
}

override fun toString(): String {
val name =
cacheName
?: return "Fingerprint-only: $contentsCount contents, " +
"fingerprint=${fingerprint.take(8)}..."
val cacheId = name.substringAfterLast("/")
return "Cache $cacheId: used $invocationsUsed invocations, cached $contentsCount contents"
}

private companion object {
// Buffer applied when checking expiry so a cache nearing expiry is treated as expired.
val EXPIRY_BUFFER = 2.minutes
}
}
10 changes: 10 additions & 0 deletions core/src/commonMain/kotlin/com/google/adk/kt/models/LlmRequest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package com.google.adk.kt.models

import com.google.adk.kt.agents.ContextCacheConfig
import com.google.adk.kt.annotations.FrameworkInternalApi
import com.google.adk.kt.serialization.adkJson
import com.google.adk.kt.tools.BaseTool
Expand All @@ -38,12 +39,21 @@ import kotlinx.serialization.json.encodeToJsonElement
* @property contents The contents of the request.
* @property config The configuration for generating content.
* @property toolsDict Internal mapping of tools added during request processing.
* @property cacheConfig Context cache configuration for this request. When `null`, context caching
* is disabled for the request.
* @property cacheMetadata Cache metadata carried over from previous requests, used to validate and
* reuse an existing cache.
* @property cacheableContentsTokenCount Prompt token count from the previous request, used to gate
* cache creation on a minimum size.
*/
data class LlmRequest(
val model: Model? = null,
val contents: List<Content> = emptyList(),
val config: GenerateContentConfig = GenerateContentConfig(),
internal val toolsDict: List<BaseTool> = emptyList(),
val cacheConfig: ContextCacheConfig? = null,
val cacheMetadata: CacheMetadata? = null,
val cacheableContentsTokenCount: Int? = null,
) {
/**
* Appends tools to the request and merges any new function declarations.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import kotlinx.serialization.json.encodeToJsonElement
* @property errorCode Error code if the response is an error. The code varies by model.
* @property customMetadata Optional key-value pairs labeling the response. The entire map must be
* JSON serializable.
* @property cacheMetadata Context cache metadata for this response, populated when context caching
* is enabled. `null` when caching is disabled or no cache information is available.
*/
@Serializable
data class LlmResponse(
Expand All @@ -64,6 +66,7 @@ data class LlmResponse(
val customMetadata: Map<String, @Contextual Any?>? = null,
val avgLogprobs: Double? = null,
val logprobsResult: LogprobsResult? = null,
val cacheMetadata: CacheMetadata? = null,
) {
companion object {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.google.adk.kt.runners

import com.google.adk.kt.agents.BaseAgent
import com.google.adk.kt.agents.ContextCacheConfig
import com.google.adk.kt.agents.InvocationContext
import com.google.adk.kt.agents.LlmAgent
import com.google.adk.kt.agents.ResumabilityConfig
Expand Down Expand Up @@ -67,6 +68,12 @@ abstract class AbstractRunner : Runner {
final override val pluginManager: PluginManager
final override val resumabilityConfig: ResumabilityConfig

/**
* The context cache configuration applied to invocations created by this runner, or `null` when
* context caching is disabled.
*/
val contextCacheConfig: ContextCacheConfig?

/** Creates a runner from explicit fields, not using an [App]. */
constructor(
appName: String,
Expand All @@ -85,6 +92,7 @@ abstract class AbstractRunner : Runner {
this.pluginManager = pluginManager
this.resumabilityConfig = resumabilityConfig
this.app = null
this.contextCacheConfig = null
}

/**
Expand Down Expand Up @@ -113,6 +121,7 @@ abstract class AbstractRunner : Runner {
this.memoryService = memoryService
this.pluginManager = PluginManager(app.plugins, skipClosingPlugins = skipClosingPlugins)
this.resumabilityConfig = app.resumabilityConfig ?: ResumabilityConfig()
this.contextCacheConfig = null
this.app =
app.copy(
eventsCompactionConfig =
Expand Down Expand Up @@ -546,6 +555,7 @@ abstract class AbstractRunner : Runner {
pluginManager = pluginManager,
resumabilityConfig = resumabilityConfig,
eventsCompactionConfig = app?.eventsCompactionConfig,
contextCacheConfig = contextCacheConfig,
)
.let {
// Run callbacks and append user message to session
Expand Down Expand Up @@ -602,6 +612,7 @@ abstract class AbstractRunner : Runner {
pluginManager = pluginManager,
resumabilityConfig = resumabilityConfig,
eventsCompactionConfig = app?.eventsCompactionConfig,
contextCacheConfig = contextCacheConfig,
)

val currentContext =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ internal fun GenAiGenerateContentConfig.fromGenaiSdk(): GenerateContentConfig =
mediaResolution = mediaResolution?.toKt(),
serviceTier = serviceTier?.toKt(),
routingConfig = routingConfig?.fromGenaiSdk(),
cachedContent = cachedContent,
)

/** Converts an ADK [GenerateContentConfig] to a [GenAiGenerateContentConfig] for the GenAI SDK. */
Expand All @@ -344,6 +345,7 @@ internal fun GenerateContentConfig.toGenaiSdk(): GenAiGenerateContentConfig =
mediaResolution = mediaResolution?.toGenaiSdk(),
serviceTier = serviceTier?.toGenaiSdk(),
routingConfig = routingConfig?.toGenaiSdk(),
cachedContent = cachedContent,
)

// --- GenerationConfigRoutingConfig ---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ data class GenerateContentConfig(
val frequencyPenalty: Float? = null,
val responseLogprobs: Boolean? = null,
val routingConfig: GenerationConfigRoutingConfig? = null,
val cachedContent: String? = null,
)
Loading
Loading