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
Expand Up @@ -31,6 +31,7 @@ import com.google.adk.kt.memory.MemoryService
import com.google.adk.kt.plugins.PluginManager
import com.google.adk.kt.sessions.Session
import com.google.adk.kt.sessions.SessionService
import com.google.adk.kt.summarizer.EventsCompactionConfig
import com.google.adk.kt.telemetry.EMPTY_JSON
import com.google.adk.kt.telemetry.Span
import com.google.adk.kt.telemetry.TelemetryAttributes
Expand Down Expand Up @@ -122,6 +123,14 @@ data class InvocationContext(
*/
val resumabilityConfig: ResumabilityConfig? = null,

/**
* Optional event-compaction configuration for this invocation.
*
* Threaded from the runner's [com.google.adk.kt.apps.App] so intra-invocation request processors
* (e.g. token-threshold compaction) can read it. `null` when no compaction is configured.
*/
val eventsCompactionConfig: EventsCompactionConfig? = 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 @@ -30,6 +30,7 @@ import com.google.adk.kt.logging.LoggerFactory
import com.google.adk.kt.models.Model
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.InstructionsProcessor
import com.google.adk.kt.processors.LlmRequestProcessor
Expand Down Expand Up @@ -188,6 +189,9 @@ class LlmAgent(
BasicRequestProcessor(),
RequestConfirmationProcessor(),
InstructionsProcessor(),
// Compaction should run before contents so compacted events are reflected in the model
// request context.
CompactionRequestProcessor(),
ContentsProcessor(),
AgentTransferProcessor(),
OutputSchemaProcessor(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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.processors

import com.google.adk.kt.agents.InvocationContext
import com.google.adk.kt.events.Event
import com.google.adk.kt.models.LlmRequest
import com.google.adk.kt.summarizer.TokenThresholdEventCompactor

/**
* Runs token-threshold event compaction before the conversation history is assembled for a model
* call.
*
* When the invocation's [InvocationContext.eventsCompactionConfig] has token-threshold compaction
* configured and the most recently observed prompt token count has reached the threshold, this
* appends a compaction summary event to the session. Because it runs before [ContentsProcessor],
* the freshly appended summary is reflected in the contents built for the request. The [LlmRequest]
* itself is returned unchanged.
*/
internal class CompactionRequestProcessor : LlmRequestProcessor {
override suspend fun process(
context: InvocationContext,
request: LlmRequest,
emitEvent: suspend (Event) -> Unit,
): LlmRequest {
val config = context.eventsCompactionConfig ?: return request
if (!config.hasTokenThresholdConfig()) return request
val sessionService = context.sessionService ?: return request
TokenThresholdEventCompactor(config, context.agent.name, context.branch)
.compact(context.session, sessionService)
return request
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ abstract class AbstractRunner : Runner {
userContent = newMessage,
pluginManager = pluginManager,
resumabilityConfig = resumabilityConfig,
eventsCompactionConfig = app?.eventsCompactionConfig,
)
.let {
// Run callbacks and append user message to session
Expand Down Expand Up @@ -598,6 +599,7 @@ abstract class AbstractRunner : Runner {
userContent = userMessage,
pluginManager = pluginManager,
resumabilityConfig = resumabilityConfig,
eventsCompactionConfig = app?.eventsCompactionConfig,
)

val currentContext =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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.summarizer

import com.google.adk.kt.events.Event

/** Returns true when this event carries a context-compaction summary. */
internal fun Event.isCompactionEvent(): Boolean = actions.compaction != null

/**
* Returns the longest prefix of the given window that can be summarized without splitting a
* function-call/response pair or leaving a tool-confirmation (HITL) request unresolved.
*
* Performs a single left-to-right pass tracking "open" obligations keyed by call ID: a function
* call or a tool-confirmation request opens one, and a function response with the same ID closes
* it. The prefix is safe to summarize exactly at the points where no obligation is open, so the
* longest prefix ending at such a balanced point is returned (empty if the window never reaches a
* balanced point).
*/
internal fun longestSelfContainedPrefix(events: List<Event>): List<Event> {
val openIds = mutableSetOf<String>()
var safeLength = 0
for ((index, event) in events.withIndex()) {
for (response in event.functionResponses()) {
response.id?.let(openIds::remove)
}
for (call in event.functionCalls()) {
call.id?.let(openIds::add)
}
openIds.addAll(event.actions.requestedToolConfirmations.keys)
if (openIds.isEmpty()) safeLength = index + 1
}
return events.subList(0, safeLength)
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,19 @@ package com.google.adk.kt.summarizer
* @property summarizer The [EventSummarizer] used to produce the compaction summary. When `null`,
* the runner defaults it to an [LlmEventSummarizer] backed by the root agent's model, requiring
* the root to be an `LlmAgent` (otherwise it throws at construction).
* @property tokenThreshold The most recent prompt token count that triggers intra-invocation
* token-threshold (tail-retention) compaction before an LLM call. Must be strictly positive when
* set, and must be set together with [eventRetentionSize].
* @property eventRetentionSize The number of most recent events kept raw (un-compacted) when
* token-threshold compaction fires; the older events are summarized into a single event. Must be
* non-negative when set, and must be set together with [tokenThreshold].
*/
data class EventsCompactionConfig(
val compactionInterval: Int? = null,
val overlapSize: Int? = null,
val summarizer: EventSummarizer? = null,
val tokenThreshold: Int? = null,
val eventRetentionSize: Int? = null,
) {
init {
if (compactionInterval != null) {
Expand All @@ -46,8 +54,23 @@ data class EventsCompactionConfig(
"compactionInterval and overlapSize must be set together or both null " +
"(got compactionInterval=$compactionInterval, overlapSize=$overlapSize)."
}
if (tokenThreshold != null) {
require(tokenThreshold > 0) { "tokenThreshold must be > 0, but was $tokenThreshold." }
}
if (eventRetentionSize != null) {
require(eventRetentionSize >= 0) {
"eventRetentionSize must be >= 0, but was $eventRetentionSize."
}
}
require((tokenThreshold == null) == (eventRetentionSize == null)) {
"tokenThreshold and eventRetentionSize must be set together or both null " +
"(got tokenThreshold=$tokenThreshold, eventRetentionSize=$eventRetentionSize)."
}
}

/** Returns true when sliding-window compaction is fully configured. */
fun hasSlidingWindowConfig(): Boolean = compactionInterval != null && overlapSize != null

/** Returns true when token-threshold compaction is fully configured. */
fun hasTokenThresholdConfig(): Boolean = tokenThreshold != null && eventRetentionSize != null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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.summarizer

import com.google.adk.kt.agents.LlmAgent.IncludeContents
import com.google.adk.kt.events.Event
import com.google.adk.kt.processors.HistoryRewriterProcessor

/** Approximate number of characters per token used by [estimatePromptTokenCount]. */
private const val CHARS_PER_TOKEN = 4

/**
* Returns the most recently observed prompt token count from [events], or `null` when one cannot be
* determined.
*
* Walks [events] from newest to oldest and returns the first non-null
* [Event.usageMetadata]`.promptTokenCount` it finds (the token count the model reported for the
* most recent LLM call). When no event carries usage metadata yet -- e.g. before the first model
* response of a session -- it falls back to [estimatePromptTokenCount].
*
* @param events The session events to inspect.
* @param agentName The current agent name, used by the estimate fallback to build effective prompt
* contents.
* @param branch The current invocation branch, used by the estimate fallback.
*/
internal fun latestPromptTokenCount(events: List<Event>, agentName: String, branch: String?): Int? {
for (event in events.asReversed()) {
if (event.usageMetadata?.promptTokenCount != null) return event.usageMetadata.promptTokenCount
}
return estimatePromptTokenCount(events, agentName, branch)
}

/**
* Returns an approximate prompt token count from session events, or `null` when the prompt has no
* text.
*
* Builds the same contents the [HistoryRewriterProcessor] would produce for the model (so compacted
* ranges and rewinds are reflected), sums the characters across all text parts, and divides by
* [CHARS_PER_TOKEN].
*/
private fun estimatePromptTokenCount(
events: List<Event>,
agentName: String,
branch: String?,
): Int? {
val contents =
HistoryRewriterProcessor()
.rewrite(
events = events,
agentName = agentName,
currentBranch = branch,
includeContents = IncludeContents.DEFAULT,
)
var totalChars = 0
for (content in contents) {
for (part in content.parts) {
totalChars += part.text?.length ?: 0
}
}
if (totalChars <= 0) return null
return totalChars / CHARS_PER_TOKEN
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,35 +111,6 @@ class SlidingWindowEventCompactor(private val config: EventsCompactionConfig) :
}

if (invocationsToCompact.size < compactionInterval) return null
return eventsToCompact.asReversed().longestSelfContainedPrefix().ifEmpty { null }
return longestSelfContainedPrefix(eventsToCompact.asReversed()).ifEmpty { null }
}
}

private fun Event.isCompactionEvent(): Boolean = actions.compaction != null

/**
* Returns the longest prefix of the given window that can be summarized without splitting a
* function-call/response pair or leaving a tool-confirmation (HITL) request unresolved.
*
* Performs a single left-to-right pass tracking "open" obligations keyed by call ID: a function
* call or a tool-confirmation request opens one, and a function response with the same ID closes
* it. The prefix is safe to summarize exactly at the points where no obligation is open, so the
* longest prefix ending at such a balanced point is returned (empty if the window never reaches a
* balanced point).
*/
private fun List<Event>.longestSelfContainedPrefix(): List<Event> {
val openIds = mutableSetOf<String>()
var safeLength = 0
for ((index, event) in withIndex()) {
for (response in event.functionResponses()) {
response.id?.let(openIds::remove)
}
for (call in event.functionCalls()) {
call.id?.let(openIds::add)
}
openIds.addAll(event.actions.requestedToolConfirmations.keys)
// TODO(b/505630632): Add authentication requests handling.
if (openIds.isEmpty()) safeLength = index + 1
}
return subList(0, safeLength)
}
Loading
Loading