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
9 changes: 8 additions & 1 deletion 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.summarizer.EventsCompactionConfig

/**
* Represents an LLM-backed agentic application.
Expand All @@ -31,8 +32,14 @@ import com.google.adk.kt.agents.BaseAgent
*
* @property appName The application name.
* @property rootAgent The root agent of the application's agent tree.
* @property eventsCompactionConfig Optional configuration controlling context-compaction strategies
* for sessions of this application. When `null`, no compaction runs.
*/
data class App(val appName: String, val rootAgent: BaseAgent) {
data class App(
val appName: String,
val rootAgent: BaseAgent,
val eventsCompactionConfig: EventsCompactionConfig? = null,
) {
init {
require(IDENTIFIER_REGEX.matches(appName)) {
"Invalid app name '$appName': must be a valid identifier consisting of letters, digits, " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,27 @@ internal class HistoryRewriterProcessor {
shouldIncludeEventInContext(currentBranch, it)
}

// Process events
// Process events. Compaction events are kept here (they carry their summary in
// actions.compaction rather than content) so processCompactionEvents can expand them below.
val filteredEvents = rawFilteredEvents.mapNotNull { event ->
when {
event.actions.compaction != null -> event
event.content == null -> null
isOtherAgentReply(agentName, event) -> presentOtherAgentMessage(event)
else -> event
}
}

// Replace each compaction event with its summary and drop the raw events it covers.
val eventsWithCompactionApplied =
if (filteredEvents.any { it.actions.compaction != null }) {
processCompactionEvents(filteredEvents)
} else {
filteredEvents
}

// Rearrange for latest function response (merge scenarios) and async function responses
return filteredEvents
return eventsWithCompactionApplied
.let { rearrangeEventsForLatestFunctionResponse(it) }
.let { rearrangeEventsForAsyncFunctionResponsesInHistory(it) }
.mapNotNull { event ->
Expand All @@ -69,6 +79,72 @@ internal class HistoryRewriterProcessor {
}
}

/**
* Processes events by applying compaction. Identifies compacted ranges and filters out events
* that are covered by compaction summaries.
*
* @param events The list of events to process.
* @return The list of events with compaction applied.
*/
private fun processCompactionEvents(events: List<Event>): List<Event> {
// Extract all compaction ranges from the events.
val compactionRanges = events.mapIndexedNotNull { index, event ->
event.actions.compaction?.let { CompactionRange(index, it.startTimestamp, it.endTimestamp) }
}
val coveredIndices = coveredCompactionRangeIndices(compactionRanges)
val keptCompactionRanges = compactionRanges.filter { it.index !in coveredIndices }

data class Item(val timestamp: Long, val index: Int, val event: Event)

val finalItems = mutableListOf<Item>()

// Pass 1: append all kept compaction events.
for (range in keptCompactionRanges) {
val compaction = events[range.index].actions.compaction!!
finalItems.add(
Item(
compaction.endTimestamp,
range.index,
events[range.index].copy(
author = Role.MODEL,
content = compaction.compactedContent,
timestamp = compaction.endTimestamp,
),
)
)
}

// Pass 2: append raw (non-compaction) events that don't fall into a kept compaction range.
finalItems +=
events
.withIndex()
.filter { (_, event) -> event.actions.compaction == null }
.filter { (_, event) -> keptCompactionRanges.none { event.timestamp in it.start..it.end } }
.map { (index, event) -> Item(event.timestamp, index, event) }

return finalItems.sortedWith(compareBy({ it.timestamp }, { it.index })).map { it.event }
}

/**
* Returns the indices of [ranges] that are fully contained by another range. When two ranges are
* identical only the later one is kept; partially overlapping ranges (neither containing the
* other) are both kept.
*/
private fun coveredCompactionRangeIndices(ranges: List<CompactionRange>): Set<Int> =
ranges.filter { range -> ranges.any { it.covers(range) } }.map { it.index }.toSet()

private data class CompactionRange(val index: Int, val start: Long, val end: Long) {
/**
* True if this range fully contains [other] -- strictly larger on at least one side, or
* identical but appearing later (so equal ranges keep only the most recent).
*/
fun covers(other: CompactionRange): Boolean =
index != other.index &&
start <= other.start &&
end >= other.end &&
(start < other.start || end > other.end || index > other.index)
}

/**
* Returns the suffix of [events] that belongs to the current turn.
*
Expand Down Expand Up @@ -220,6 +296,9 @@ internal class HistoryRewriterProcessor {
* Parts with only thoughts are also considered empty.
*/
private fun containsEmptyContent(event: Event): Boolean {
// Compaction events carry their summary in actions.compaction rather than content; keep them so
// processCompactionEvents can expand them into summary content.
if (event.actions.compaction != null) return false

val hasContent =
event.content != null &&
Expand Down
116 changes: 90 additions & 26 deletions core/src/commonMain/kotlin/com/google/adk/kt/runners/AbstractRunner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package com.google.adk.kt.runners

import com.google.adk.kt.agents.BaseAgent
import com.google.adk.kt.agents.InvocationContext
import com.google.adk.kt.agents.LlmAgent
import com.google.adk.kt.agents.ResumabilityConfig
import com.google.adk.kt.agents.RunConfig
import com.google.adk.kt.agents.findAgent
Expand All @@ -41,6 +42,9 @@ import com.google.adk.kt.sessions.Session
import com.google.adk.kt.sessions.SessionKey
import com.google.adk.kt.sessions.SessionService
import com.google.adk.kt.sessions.State
import com.google.adk.kt.summarizer.EventsCompactionConfig
import com.google.adk.kt.summarizer.LlmEventSummarizer
import com.google.adk.kt.summarizer.SlidingWindowEventCompactor
import com.google.adk.kt.telemetry.trace
import com.google.adk.kt.types.Blob
import com.google.adk.kt.types.Content
Expand All @@ -53,39 +57,64 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking

/**
* An abstract base class for [Runner] implementations that provides common orchestration logic.
*
* Subclasses may be constructed either from explicit fields or from an [App] (via the [App]-based
* constructor), which supplies the [App.appName] and [App.rootAgent].
*/
abstract class AbstractRunner(
override val appName: String,
override val agent: BaseAgent,
override val sessionService: SessionService,
override val artifactService: ArtifactService?,
override val memoryService: MemoryService?,
override val pluginManager: PluginManager,
override val resumabilityConfig: ResumabilityConfig = ResumabilityConfig(),
) : Runner {

/** Creates a runner from an [App], taking its [App.appName] and [App.rootAgent]. */
/** An abstract base class for [Runner] implementations that provides common orchestration logic. */
abstract class AbstractRunner : Runner {

val app: App?
final override val appName: String
final override val agent: BaseAgent
final override val sessionService: SessionService
final override val artifactService: ArtifactService?
final override val memoryService: MemoryService?
final override val pluginManager: PluginManager
final override val resumabilityConfig: ResumabilityConfig

/** Creates a runner from explicit fields, not using an [App]. */
constructor(
appName: String,
agent: BaseAgent,
sessionService: SessionService,
artifactService: ArtifactService?,
memoryService: MemoryService?,
pluginManager: PluginManager,
resumabilityConfig: ResumabilityConfig = ResumabilityConfig(),
) {
this.appName = appName
this.agent = agent
this.sessionService = sessionService
this.artifactService = artifactService
this.memoryService = memoryService
this.pluginManager = pluginManager
this.resumabilityConfig = resumabilityConfig
this.app = null
}

/**
* Creates a runner from an [App]. The compaction config is resolved at construction (failing fast
* if a default summarizer is required but the root agent is not an [LlmAgent]) and stored back on
* the [app], so [App.eventsCompactionConfig] returns the effective config.
*/
constructor(
app: App,
sessionService: SessionService,
artifactService: ArtifactService?,
memoryService: MemoryService?,
pluginManager: PluginManager,
resumabilityConfig: ResumabilityConfig = ResumabilityConfig(),
) : this(
app.appName,
app.rootAgent,
sessionService,
artifactService,
memoryService,
pluginManager,
resumabilityConfig,
)
) {
this.appName = app.appName
this.agent = app.rootAgent
this.sessionService = sessionService
this.artifactService = artifactService
this.memoryService = memoryService
this.pluginManager = pluginManager
this.resumabilityConfig = resumabilityConfig
this.app =
app.copy(
eventsCompactionConfig =
resolveEventsCompactionConfig(app.rootAgent, app.eventsCompactionConfig)
)
}

/**
* Main entry method to run the agent in this runner.
Expand Down Expand Up @@ -125,6 +154,10 @@ abstract class AbstractRunner(

// 4. Run agent with plugins
emitAll(runAgentWithPlugins(context))

// 5. Post-invocation context compaction. Runs once the agent has finished emitting and all
// its events have been appended to `session`.
runPostInvocationCompaction(session)
}
.trace("invocation")

Expand Down Expand Up @@ -680,7 +713,38 @@ abstract class AbstractRunner(
return "e-" + Uuid.random()
}

/**
* Runs post-invocation sliding-window context compaction over [session] when configured. A no-op
* when no compaction config was supplied or sliding-window compaction is not configured. The
* compactor appends a single summary [Event] to [session] (via [sessionService]) when the
* configured invocation interval is reached.
*/
private suspend fun runPostInvocationCompaction(session: Session) {
val config = app?.eventsCompactionConfig ?: return
if (!config.hasSlidingWindowConfig()) return
SlidingWindowEventCompactor(config).compact(session, sessionService)
}

private companion object {
private val logger = LoggerFactory.getLogger(AbstractRunner::class)

/**
* Returns [config] with a default [LlmEventSummarizer] injected when compaction is configured
* but no summarizer was supplied. The default summarizer uses [rootAgent]'s model, so
* [rootAgent] must be an [LlmAgent] in that case; otherwise an [IllegalArgumentException] is
* thrown. Returns [config] unchanged when it is `null` or already carries a summarizer. Any
* configured compaction strategy needs a summarizer, so this does not depend on which strategy
* is set.
*/
private fun resolveEventsCompactionConfig(
rootAgent: BaseAgent,
config: EventsCompactionConfig?,
): EventsCompactionConfig? {
if (config == null || config.summarizer != null) return config
val model =
(rootAgent as? LlmAgent)?.model
?: throw IllegalArgumentException("No BaseLlm model available for event compaction")
return config.copy(summarizer = LlmEventSummarizer(model))
}
}
}
18 changes: 18 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 @@ -16,10 +16,12 @@

package com.google.adk.kt.apps

import com.google.adk.kt.summarizer.EventsCompactionConfig
import com.google.adk.kt.testing.DummyAgent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertSame

class AppTest {
Expand All @@ -34,6 +36,22 @@ class AppTest {
assertSame(agent, app.rootAgent)
}

@Test
fun construct_noEventsCompactionConfig_defaultsToNull() {
val app = App(appName = "my_app", rootAgent = DummyAgent())

assertNull(app.eventsCompactionConfig)
}

@Test
fun construct_withEventsCompactionConfig_exposesIt() {
val config = EventsCompactionConfig(compactionInterval = 2, overlapSize = 1)

val app = App(appName = "my_app", rootAgent = DummyAgent(), eventsCompactionConfig = config)

assertSame(config, app.eventsCompactionConfig)
}

@Test
fun construct_emptyName_throwsIllegalArgumentException() {
assertFailsWith<IllegalArgumentException> { App(appName = "", rootAgent = DummyAgent()) }
Expand Down
Loading
Loading