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,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"
}
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
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
}
}
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> { ContextCacheConfig(maxInvocations = 0) }
}

@Test
fun construct_maxInvocationsTooHigh_throwsIllegalArgumentException() {
assertFailsWith<IllegalArgumentException> { ContextCacheConfig(maxInvocations = 101) }
}

@Test
fun construct_nonPositiveTtl_throwsIllegalArgumentException() {
assertFailsWith<IllegalArgumentException> { ContextCacheConfig(ttl = 0.seconds) }
}

@Test
fun construct_negativeMinTokens_throwsIllegalArgumentException() {
assertFailsWith<IllegalArgumentException> { ContextCacheConfig(minTokens = -1) }
}
}
17 changes: 17 additions & 0 deletions core/src/commonTest/kotlin/com/google/adk/kt/apps/AppTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<IllegalArgumentException> { App(appName = "", rootAgent = DummyAgent()) }
Expand Down
Loading
Loading