From 8b941050a2571ff920345f59cd2b1110f0688a46 Mon Sep 17 00:00:00 2001 From: Krzysztof Zagrajek Date: Tue, 23 Jun 2026 08:10:37 -0700 Subject: [PATCH] feat: Add CacheMetadata model for ADK Kotlin context caching PiperOrigin-RevId: 936677533 --- .../adk/kt/agents/ContextCacheConfig.kt | 56 ++++++++ .../kotlin/com/google/adk/kt/apps/App.kt | 4 + .../com/google/adk/kt/models/CacheMetadata.kt | 95 ++++++++++++++ .../adk/kt/agents/ContextCacheConfigTest.kt | 73 +++++++++++ .../kotlin/com/google/adk/kt/apps/AppTest.kt | 17 +++ .../google/adk/kt/models/CacheMetadataTest.kt | 123 ++++++++++++++++++ 6 files changed, 368 insertions(+) create mode 100644 core/src/commonMain/kotlin/com/google/adk/kt/agents/ContextCacheConfig.kt create mode 100644 core/src/commonMain/kotlin/com/google/adk/kt/models/CacheMetadata.kt create mode 100644 core/src/commonTest/kotlin/com/google/adk/kt/agents/ContextCacheConfigTest.kt create mode 100644 core/src/commonTest/kotlin/com/google/adk/kt/models/CacheMetadataTest.kt diff --git a/core/src/commonMain/kotlin/com/google/adk/kt/agents/ContextCacheConfig.kt b/core/src/commonMain/kotlin/com/google/adk/kt/agents/ContextCacheConfig.kt new file mode 100644 index 00000000..87bf01e1 --- /dev/null +++ b/core/src/commonMain/kotlin/com/google/adk/kt/agents/ContextCacheConfig.kt @@ -0,0 +1,56 @@ +/* + * 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 kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Configuration for context caching across all agents in an app. + * + * This configuration enables and controls context caching behavior for all LLM 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. + * + * @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 estimated request tokens required to enable caching. This compares + * against the estimated total tokens of the request (system instruction + tools + contents). + * 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( + 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" +} diff --git a/core/src/commonMain/kotlin/com/google/adk/kt/apps/App.kt b/core/src/commonMain/kotlin/com/google/adk/kt/apps/App.kt index 3d9e86e8..190e52b2 100644 --- a/core/src/commonMain/kotlin/com/google/adk/kt/apps/App.kt +++ b/core/src/commonMain/kotlin/com/google/adk/kt/apps/App.kt @@ -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 @@ -42,6 +43,8 @@ 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, @@ -49,6 +52,7 @@ data class App( val plugins: List = emptyList(), val resumabilityConfig: ResumabilityConfig? = null, val eventsCompactionConfig: EventsCompactionConfig? = null, + val contextCacheConfig: ContextCacheConfig? = null, ) { init { require(IDENTIFIER_REGEX.matches(appName)) { diff --git a/core/src/commonMain/kotlin/com/google/adk/kt/models/CacheMetadata.kt b/core/src/commonMain/kotlin/com/google/adk/kt/models/CacheMetadata.kt new file mode 100644 index 00000000..3b156f41 --- /dev/null +++ b/core/src/commonMain/kotlin/com/google/adk/kt/models/CacheMetadata.kt @@ -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 + } +} diff --git a/core/src/commonTest/kotlin/com/google/adk/kt/agents/ContextCacheConfigTest.kt b/core/src/commonTest/kotlin/com/google/adk/kt/agents/ContextCacheConfigTest.kt new file mode 100644 index 00000000..8acac1c3 --- /dev/null +++ b/core/src/commonTest/kotlin/com/google/adk/kt/agents/ContextCacheConfigTest.kt @@ -0,0 +1,73 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.time.Duration.Companion.seconds + +class ContextCacheConfigTest { + + @Test + fun construct_defaults_usesDocumentedValues() { + val config = ContextCacheConfig() + + assertEquals(10, config.maxInvocations) + assertEquals(1800.seconds, config.ttl) + assertEquals(0, config.minTokens) + } + + @Test + fun ttlString_default_formatsSeconds() { + assertEquals("1800s", ContextCacheConfig().ttlString) + } + + @Test + fun ttlString_customTtl_formatsWholeSeconds() { + assertEquals("60s", ContextCacheConfig(ttl = 60.seconds).ttlString) + } + + @Test + fun construct_customValues_exposesProperties() { + val config = ContextCacheConfig(maxInvocations = 5, ttl = 120.seconds, minTokens = 4096) + + assertEquals(5, config.maxInvocations) + assertEquals(120.seconds, config.ttl) + assertEquals(4096, config.minTokens) + } + + @Test + fun construct_maxInvocationsTooLow_throwsIllegalArgumentException() { + assertFailsWith { ContextCacheConfig(maxInvocations = 0) } + } + + @Test + fun construct_maxInvocationsTooHigh_throwsIllegalArgumentException() { + assertFailsWith { ContextCacheConfig(maxInvocations = 101) } + } + + @Test + fun construct_nonPositiveTtl_throwsIllegalArgumentException() { + assertFailsWith { ContextCacheConfig(ttl = 0.seconds) } + } + + @Test + fun construct_negativeMinTokens_throwsIllegalArgumentException() { + assertFailsWith { ContextCacheConfig(minTokens = -1) } + } +} diff --git a/core/src/commonTest/kotlin/com/google/adk/kt/apps/AppTest.kt b/core/src/commonTest/kotlin/com/google/adk/kt/apps/AppTest.kt index 23b6911b..1bf77e41 100644 --- a/core/src/commonTest/kotlin/com/google/adk/kt/apps/AppTest.kt +++ b/core/src/commonTest/kotlin/com/google/adk/kt/apps/AppTest.kt @@ -18,6 +18,7 @@ package com.google.adk.kt.apps +import com.google.adk.kt.agents.ContextCacheConfig import com.google.adk.kt.agents.ResumabilityConfig import com.google.adk.kt.annotations.ExperimentalResumabilityFeature import com.google.adk.kt.plugins.Plugin @@ -57,6 +58,22 @@ class AppTest { assertSame(config, app.eventsCompactionConfig) } + @Test + fun construct_noContextCacheConfig_defaultsToNull() { + val app = App(appName = "my_app", rootAgent = DummyAgent()) + + assertNull(app.contextCacheConfig) + } + + @Test + fun construct_withContextCacheConfig_exposesIt() { + val config = ContextCacheConfig(maxInvocations = 5) + + val app = App(appName = "my_app", rootAgent = DummyAgent(), contextCacheConfig = config) + + assertSame(config, app.contextCacheConfig) + } + @Test fun construct_emptyName_throwsIllegalArgumentException() { assertFailsWith { App(appName = "", rootAgent = DummyAgent()) } diff --git a/core/src/commonTest/kotlin/com/google/adk/kt/models/CacheMetadataTest.kt b/core/src/commonTest/kotlin/com/google/adk/kt/models/CacheMetadataTest.kt new file mode 100644 index 00000000..303cc291 --- /dev/null +++ b/core/src/commonTest/kotlin/com/google/adk/kt/models/CacheMetadataTest.kt @@ -0,0 +1,123 @@ +/* + * 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.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Clock + +class CacheMetadataTest { + + @Test + fun construct_fingerprintOnly_isNotActive() { + val metadata = CacheMetadata(fingerprint = "abc123", contentsCount = 3) + + assertFalse(metadata.isActive) + assertFalse(metadata.expireSoon) + } + + @Test + fun construct_activeCache_exposesProperties() { + val metadata = + CacheMetadata( + fingerprint = "abc123", + contentsCount = 3, + cacheName = "projects/p/locations/l/cachedContents/456", + expireTime = Clock.System.now().toEpochMilliseconds() + 1_800_000, + invocationsUsed = 1, + createdAt = Clock.System.now().toEpochMilliseconds(), + ) + + assertTrue(metadata.isActive) + assertEquals(1, metadata.invocationsUsed) + } + + @Test + fun construct_partialActiveState_throwsIllegalArgumentException() { + // cacheName set but expireTime/invocationsUsed null violates the active-state invariant. + assertFailsWith { + CacheMetadata(fingerprint = "abc", contentsCount = 1, cacheName = "cache/1") + } + } + + @Test + fun construct_negativeContentsCount_throwsIllegalArgumentException() { + assertFailsWith { + CacheMetadata(fingerprint = "abc", contentsCount = -1) + } + } + + @Test + fun construct_negativeInvocationsUsed_throwsIllegalArgumentException() { + assertFailsWith { + CacheMetadata( + fingerprint = "abc", + contentsCount = 1, + cacheName = "cache/1", + expireTime = 1L, + invocationsUsed = -1, + ) + } + } + + @Test + fun expireSoon_pastExpiry_isTrue() { + val metadata = + CacheMetadata( + fingerprint = "abc", + contentsCount = 1, + cacheName = "cache/1", + expireTime = Clock.System.now().toEpochMilliseconds() - 1_000, + invocationsUsed = 1, + ) + + assertTrue(metadata.expireSoon) + } + + @Test + fun expireSoon_farFutureExpiry_isFalse() { + val metadata = + CacheMetadata( + fingerprint = "abc", + contentsCount = 1, + cacheName = "cache/1", + expireTime = Clock.System.now().toEpochMilliseconds() + 3_600_000, + invocationsUsed = 1, + ) + + assertFalse(metadata.expireSoon) + } + + @Test + fun copy_incrementInvocationsUsed_producesUpdatedCopy() { + val metadata = + CacheMetadata( + fingerprint = "abc", + contentsCount = 1, + cacheName = "cache/1", + expireTime = Clock.System.now().toEpochMilliseconds() + 1_000, + invocationsUsed = 1, + ) + + val updated = metadata.copy(invocationsUsed = metadata.invocationsUsed!! + 1) + + assertEquals(2, updated.invocationsUsed) + assertEquals(1, metadata.invocationsUsed) + } +}