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,24 @@
/*
* 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.crypto

import java.security.MessageDigest

internal actual fun sha256Hex(input: String): String =
MessageDigest.getInstance("SHA-256").digest(input.encodeToByteArray()).joinToString("") {
(it.toInt() and 0xff).toString(16).padStart(2, '0')
}
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 cacheIntervals 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 cacheIntervals: Int = 10,
val ttl: Duration = 1800.seconds,
val minTokens: Int = 0,
) {
init {
require(cacheIntervals in 1..100) {
"cacheIntervals must be in 1..100, but was $cacheIntervals."
}
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 @@ -32,6 +32,7 @@ import com.google.adk.kt.processors.AgentTransferProcessor
import com.google.adk.kt.processors.BasicRequestProcessor
import com.google.adk.kt.processors.CompactionRequestProcessor
import com.google.adk.kt.processors.ContentsProcessor
import com.google.adk.kt.processors.ContextCacheRequestProcessor
import com.google.adk.kt.processors.InstructionsProcessor
import com.google.adk.kt.processors.LlmRequestProcessor
import com.google.adk.kt.processors.LlmResponseProcessor
Expand Down Expand Up @@ -193,6 +194,7 @@ class LlmAgent(
// request context.
CompactionRequestProcessor(),
ContentsProcessor(),
ContextCacheRequestProcessor(),
AgentTransferProcessor(),
OutputSchemaProcessor(),
)
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/apps/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.google.adk.kt.apps

import com.google.adk.kt.agents.BaseAgent
import com.google.adk.kt.agents.ContextCacheConfig
import com.google.adk.kt.agents.ResumabilityConfig
import com.google.adk.kt.plugins.Plugin
import com.google.adk.kt.summarizer.EventsCompactionConfig
Expand All @@ -42,13 +43,16 @@ import com.google.adk.kt.summarizer.EventsCompactionConfig
* sessions. When `null`, resumability is disabled.
* @property eventsCompactionConfig Optional configuration controlling context-compaction strategies
* for sessions of this application. When `null`, no compaction runs.
* @property contextCacheConfig Optional context cache configuration that applies to all LLM agents
* in the app. When `null`, context caching is disabled.
*/
data class App(
val appName: String,
val rootAgent: BaseAgent,
val plugins: List<Plugin> = emptyList(),
val resumabilityConfig: ResumabilityConfig? = null,
val eventsCompactionConfig: EventsCompactionConfig? = null,
val contextCacheConfig: ContextCacheConfig? = null,
) {
init {
require(IDENTIFIER_REGEX.matches(appName)) {
Expand Down
20 changes: 20 additions & 0 deletions core/src/commonMain/kotlin/com/google/adk/kt/crypto/Sha256.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* 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.crypto

/** Returns the lowercase hex-encoded SHA-256 digest of [input]'s UTF-8 bytes. */
internal expect fun sha256Hex(input: String): String
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,99 @@
/*
* 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.math.round
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("/")
val minutesToExpiry = (expireTime!! - Clock.System.now().toEpochMilliseconds()) / 60_000.0
val roundedMinutes = round(minutesToExpiry * 10) / 10
return "Cache $cacheId: used $invocationsUsed invocations, cached $contentsCount contents, " +
"expires in ${roundedMinutes}min"
}

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

override fun generateContent(request: LlmRequest, stream: Boolean): Flow<LlmResponse> = flow {
val preparedRequest = request.prepareGenerateContentRequest(!client.enterprise)
val config = preparedRequest.config.toGenaiSdk()
val contents = preparedRequest.contents.map { it.toGenaiSdk() }

logger.debug { "LLM Request:\n${Json.toJsonString(buildLoggingRequestMap(preparedRequest))}" }
// Handle context caching when configured. The manager may rewrite the request to reference an
// existing cache (dropping the cached prefix) and returns the metadata to attach to responses.
val cacheManager =
preparedRequest.cacheConfig?.let {
GeminiContextCacheManager(name, GenaiCacheClient(client.caches))
}
val cacheResult = cacheManager?.handleContextCaching(preparedRequest)
val finalRequest = cacheResult?.request ?: preparedRequest
val cacheMetadata = cacheResult?.cacheMetadata

val config = finalRequest.config.toGenaiSdk()
val contents = finalRequest.contents.map { it.toGenaiSdk() }

logger.debug { "LLM Request:\n${Json.toJsonString(buildLoggingRequestMap(finalRequest))}" }

if (stream) {
val aggregator = StreamingResponseAggregator()
Expand All @@ -148,16 +159,16 @@ class Gemini(
emit(aggregator.processResponse(response.fromGenaiSdk()))
}

// After stream loop ends, emit final aggregated response
aggregator.aggregate()?.let { emit(it) }
// After stream loop ends, emit final aggregated response with any cache metadata attached
aggregator.aggregate()?.let { emit(it.copy(cacheMetadata = cacheMetadata)) }
} else {
val response = models.generateContent(name, contents, config)
logger.debug {
"LLM Response: ${response.candidates?.size ?: 0} candidates, " +
"finishReason=${response.candidates?.firstOrNull()?.finishReason}"
}
val llmResponse = LlmResponse.from(response.fromGenaiSdk())
emit(llmResponse)
emit(llmResponse.copy(cacheMetadata = cacheMetadata))
}
}

Expand Down
Loading
Loading