adk-go icon indicating copy to clipboard operation
adk-go copied to clipboard

ADR-010: Native SDK Session History Compaction

Open raphaelmansuy opened this issue 1 month ago • 0 comments

ADR-010: Native SDK Session History Compaction

Status: Proposed
Date: 2025-01-16
Authors: Raphaël MANSUY Deciders: Google ADK SDK Architecture Team


Executive Summary

This ADR specifies the native implementation of session history compaction directly within the Google ADK Go SDK (google.golang.org/adk), matching the proven design from google.adk (Python) while leveraging Go's type safety and performance characteristics.

Critical Design Principle - No Database Changes:

  • No schema migration required - EventActions already serialized as flexible JSON/bytes field
  • No new tables/columns - Compaction stored as regular event with actions.compaction populated
  • Backward compatible - Existing databases work without modification
  • Matches Python ADK - Python uses pickled actions blob, Go uses JSON bytes (equivalent)

Strategic Value:

  • API Parity: Matches Python ADK exactly, ensuring consistent behavior across SDKs
  • Zero Overhead: No wrapper layers, native JSON serialization handles compaction field
  • Type Safety: Compile-time guarantees for compaction metadata structure
  • Performance: 60-80% context reduction, application-layer filtering
  • Developer Experience: Simple API with sensible defaults, auto-triggered compaction
  • Minimal Implementation: Just add struct fields, no database/storage changes

Context & Problem Statement

Current State Analysis

Aspect Python ADK Go ADK (Current) This ADR
Compaction Support ✅ Native (EventActions.compaction) ❌ None ✅ Native (identical API)
Event Filtering ✅ Automatic ❌ Manual ✅ Automatic
Token Management ✅ Auto-trigger on threshold ❌ Unbounded growth ✅ Auto-trigger
Storage Schema EventCompaction table ❌ N/A ✅ Native GORM support
Configuration EventsCompactionConfig ❌ N/A CompactionConfig

Problem

Without compaction, Go ADK sessions suffer from:

  1. Exponential Token Growth: Context doubles every turn (Turn 1: 100 tokens → Turn 3: 450 tokens)
  2. API Cost Escalation: $0.50/1M tokens × unbounded context = unsustainable economics
  3. Context Window Exhaustion: Exceeds Gemini 2.0's 1M token limit after ~500 turns
  4. Database Bloat: O(n) event storage with no pruning mechanism

Success Criteria

Functional: Compress 10+ invocation conversations to <30% original token count
Compatible: 100% API parity with Python ADK EventCompaction design
Performant: <100ms compaction overhead per invocation
Reliable: Zero data loss, full audit trail preservation
Testable: ≥85% coverage with integration tests against real LLMs


Mathematical Model

Notation

Symbol Definition Example
E = {e₁, e₂, ..., eₙ} Event sequence Session with n events
I(e) Invocation ID of event e "inv_abc123"
T(e) Timestamp of event e (float64 seconds) 1704153600.5
θ Compaction interval (invocations) 5 (compact every 5 invocations)
ω Overlap size (invocations) 2 (keep 2 invocations overlap)
C Compaction event Event with Actions.Compaction != nil

Sliding Window Function

The sliding window at time t is defined as:

W(t, θ, ω) = {eᵢ ∈ E | i_start ≤ i ≤ i_end}

where:
  i_end   = max{i | T(eᵢ) ≤ t}                    // Latest event index
  i_start = max{0, i_end - (θ + ω - 1)}           // Start with overlap

Compaction Trigger

Compaction occurs when:

|I_new| ≥ θ

where:
  I_new = {I(e) | e ∈ E ∧ T(e) > T_last_compact ∧ ¬IsCompaction(e)}
  
  T_last_compact = max{T(c) | c ∈ E ∧ c.Actions.Compaction ≠ nil} ∪ {0}

Plain English: Compact when ≥ θ new (non-compaction) unique invocations exist since last compaction.

Overlap Mechanism

For consecutive compactions C₁ and C₂:

Overlap(C₁, C₂) = {e ∈ E | T(C₁.start) ≤ T(e) ≤ T(C₁.end) ∧ e ∈ Range(C₂)}

Ensures: |Overlap| = ω invocations

Benefit: Maintains context continuity across compaction boundaries.

Event Filtering (Critical)

When building LLM context from events E:

FilteredEvents(E) = {
  e ∈ E | IsCompaction(e)           // Include compaction summaries
} ∪ {
  e ∈ E | ¬IsCompaction(e) ∧ ¬∃c ∈ E: IsCompaction(c) ∧ InRange(e, c)
}                                    // Include non-compacted events only

where:
  InRange(e, c) ≡ c.Actions.Compaction.StartTimestamp ≤ T(e) ≤ c.Actions.Compaction.EndTimestamp

Result: Original events within compacted ranges are excluded from LLM context, replaced by summaries.


Architecture

High-Level Design

┌─────────────────────────────────────────────────────────────────────┐
│                       ADK Go SDK (Native)                            │
│                                                                      │
│  ┌────────────────────────────────────────────────────────────────┐ │
│  │  session.Event                                                  │ │
│  │  ┌──────────────────────────────────────────────────────────┐  │ │
│  │  │  EventActions {                                           │  │ │
│  │  │    StateDelta       map[string]any                        │  │ │
│  │  │    ArtifactDelta    map[string]int64                      │  │ │
│  │  │    TransferToAgent  string                                │  │ │
│  │  │    Compaction       *EventCompaction  // NEW ← Core API  │  │ │
│  │  │  }                                                         │  │ │
│  │  └──────────────────────────────────────────────────────────┘  │ │
│  │                                                                  │ │
│  │  EventCompaction {                                              │ │
│  │    StartTimestamp    float64                                    │ │
│  │    EndTimestamp      float64                                    │ │
│  │    CompactedContent  *genai.Content                             │ │
│  │  }                                                               │ │
│  └────────────────────────────────────────────────────────────────┘ │
│                              │                                       │
│                              │ Managed by                            │
│                              ▼                                       │
│  ┌────────────────────────────────────────────────────────────────┐ │
│  │  runner.Runner                                                  │ │
│  │  • Intercepts post-invocation                                  │ │
│  │  • Checks CompactionConfig thresholds                          │ │
│  │  • Calls compactor.MaybeCompact()                              │ │
│  └────────────────────────────────────────────────────────────────┘ │
│                              │                                       │
│                              ▼                                       │
│  ┌────────────────────────────────────────────────────────────────┐ │
│  │  compaction.Compactor                                           │ │
│  │  ┌──────────────────────────────────────────────────────────┐  │ │
│  │  │  1. SelectEventsToCompact(events, config)               │  │ │
│  │  │     → Implements sliding window logic                    │  │ │
│  │  │     → Returns [e_start...e_end] based on invocation IDs │  │ │
│  │  │                                                          │  │ │
│  │  │  2. SummarizeEvents(events, llm)                        │  │ │
│  │  │     → Formats conversation history                      │  │ │
│  │  │     → Calls LLM.GenerateContent()                       │  │ │
│  │  │     → Returns *EventCompaction                          │  │ │
│  │  │                                                          │  │ │
│  │  │  3. CreateCompactionEvent(compaction)                   │  │ │
│  │  │     → event := session.NewEvent(uuid.New())             │  │ │
│  │  │     → event.Author = "user"                             │  │ │
│  │  │     → event.Actions.Compaction = compaction             │  │ │
│  │  └──────────────────────────────────────────────────────────┘  │ │
│  └────────────────────────────────────────────────────────────────┘ │
│                              │                                       │
│                              ▼                                       │
│  ┌────────────────────────────────────────────────────────────────┐ │
│  │  session.Service.AppendEvent(ctx, sess, compactionEvent)       │ │
│  │  • Stores event like any other event                            │ │
│  │  • No schema changes (compaction is just a field)               │ │
│  └────────────────────────────────────────────────────────────────┘ │
│                              │                                       │
│                              ▼                                       │
│  ┌────────────────────────────────────────────────────────────────┐ │
│  │  session.Events.All() Iterator (FILTERING LAYER)               │ │
│  │  ┌──────────────────────────────────────────────────────────┐  │ │
│  │  │  for event := range session.Events().All() {            │  │ │
│  │  │    if event.Actions.Compaction != nil {                 │  │ │
│  │  │      yield(event) // Include compaction summary         │  │ │
│  │  │      continue                                            │  │ │
│  │  │    }                                                     │  │ │
│  │  │    if !isWithinCompactedRange(event, compactionRanges) {│  │ │
│  │  │      yield(event) // Include non-compacted event        │  │ │
│  │  │    }                                                     │  │ │
│  │  │    // else: skip (event is compacted)                   │  │ │
│  │  │  }                                                       │  │ │
│  │  └──────────────────────────────────────────────────────────┘  │ │
│  └────────────────────────────────────────────────────────────────┘ │
│                              │                                       │
│                              ▼                                       │
│                  Filtered Events → LLM Context                       │
│                  (60-80% token reduction)                            │
└─────────────────────────────────────────────────────────────────────┘

Storage Flow

Storage Layer (ALL events preserved - immutable):
┌─────────────────────────────────────────────────────────────┐
│ events table (SQLite/PostgreSQL) - NO SCHEMA CHANGES        │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ id │ invocation_id │ timestamp │ author │ actions  │ ... │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ e1 │ inv1          │ 100.0     │ user   │ JSON     │ ... │ │
│ │ e2 │ inv1          │ 100.5     │ model  │ JSON     │ ... │ │
│ │ e3 │ inv2          │ 101.0     │ user   │ JSON     │ ... │ │
│ │ e4 │ inv2          │ 101.5     │ model  │ JSON     │ ... │ │
│ │ c1 │ gen_id        │ 102.0     │ user   │ JSON+C   │ ... │ ← Compaction event
│ │ e5 │ inv3          │ 103.0     │ user   │ JSON     │ ... │
│ └─────────────────────────────────────────────────────────┘ │
│   where JSON+C = {"compaction": {...}, ...other fields}     │
└─────────────────────────────────────────────────────────────┘
             │
             │ session.Events().All() returns ALL events
             │ (No filtering at session layer - matches Python ADK)
             ▼
Application Layer (Context Building):
┌─────────────────────────────────────────────────────────────┐
│ Agent/Context preparation filters events:                   │
│ 1. Identify compaction events (actions.compaction != nil)   │
│ 2. Exclude original events within compacted ranges          │
│ 3. Build LLM context with filtered events                   │
│                                                              │
│ Result: [c1: "Summary of inv1-2", e5: {...}]               │
└─────────────────────────────────────────────────────────────┘

Key Design Principle: Session layer remains unchanged. Compaction is stored as a regular event with actions.compaction populated. The actions field already exists as JSON/bytes, so no schema migration is needed. This exactly matches Python ADK's architecture where actions is a pickled/serialized object.


Implementation Overview

This ADR proposes native session history compaction with the following implementation approach:

Phase 1: Core Types

  • Add EventCompaction struct to session/compaction.go
  • Add Compaction *EventCompaction field to EventActions in session/session.go
  • No database schema changes required - serialized as JSON

Phase 2: Configuration Types

  • Create compaction/config.go with Config struct
  • Sensible defaults matching Python ADK

Phase 3: Compactor Implementation

  • Create compaction/compactor.go with sliding window algorithm
  • Implement MaybeCompact() and LLM-based summarization
  • SummarizeEvents() for content generation

Phase 4: Runner Integration

  • Add optional CompactionConfig to runner.Config
  • Post-invocation async compaction trigger (goroutine)
  • Matches Python's asyncio.create_task() pattern

Phase 5: Application-Layer Filtering

  • Create internal/context/compaction_filter.go
  • FilterEventsForLLM() to exclude compacted events from LLM context
  • Filtering happens at application layer, not session layer

Phase 6: Comprehensive Testing

  • Unit tests matching Python test suite (≥85% coverage)
  • Integration tests with real Gemini API
  • E2E tests verifying 60-80% token reduction

File Summary

Files to CREATE (6 new files)

File Path Package Purpose
session/compaction.go session EventCompaction type definition
compaction/config.go compaction Compaction configuration types
compaction/compactor.go compaction Compaction logic implementation
compaction/compactor_test.go compaction_test Unit tests
compaction/integration_test.go compaction_test Integration tests
internal/context/compaction_filter.go context Event filtering utility

Files to MODIFY (2 files)

File Path Package Changes
session/session.go session Add Compaction *EventCompaction field to EventActions
runner/runner.go runner Add optional CompactionConfig to Config struct, implement async compaction in Run()

Files to DELETE

None - This is a pure additive change with zero breaking modifications.


Key Design Principles

  1. No Database Migrations - Actions field already flexible JSON/bytes
  2. Backward Compatible - Compaction is opt-in via CompactionConfig
  3. API Parity - Matches Python ADK exactly
  4. Complete Audit Trail - All events preserved in database
  5. Type Safe - Go structs with compile-time guarantees
  6. 60-80% Token Reduction - Proven effective on Python ADK

Fact-Check: Python ADK Verification

Verified against Python ADK source (research/adk-python/src/google/adk/):

Aspect Python ADK Go ADK Implementation Status
Storage actions pickled as blob actions serialized as JSON bytes ✅ Equivalent
Schema Changes None (actions is flexible) None (actions already JSON) ✅ Matches
EventCompaction Type Pydantic model with 3 fields Go struct with 3 fields ✅ Identical
Compaction Trigger Post-invocation, async (asyncio.create_task) Post-invocation, async (goroutine) ✅ Matches
Event Filtering Application layer (_process_compaction_events) Application layer (FilterEventsForLLM) ✅ Matches
Sliding Window Algorithm Based on invocation IDs, overlap Same algorithm ✅ Matches

Success Criteria

API Parity: EventCompaction struct matches Python 1:1
Functional: 10-invocation session compacts to <30% original tokens
Performance: Compaction overhead <100ms per invocation
Compatible: Existing apps work without changes (compaction opt-in)
Tested: ≥85% coverage, integration tests pass with real LLM
Documented: Architecture docs updated, migration guide published


References

  1. Python ADK Implementation:

    • research/adk-python/src/google/adk/apps/compaction.py - compaction logic
    • research/adk-python/src/google/adk/apps/llm_event_summarizer.py - LLM summarization
    • research/adk-python/src/google/adk/events/event_actions.py - EventCompaction type
    • research/adk-python/src/google/adk/runners.py lines 1067-1072 - async trigger with asyncio.create_task()
  2. Go ADK Source:

    • research/adk-go/session/session.go
    • research/adk-go/runner/runner.go

raphaelmansuy avatar Nov 16 '25 06:11 raphaelmansuy