Skip to main content

adk_core/
intra_compaction.rs

1//! Intra-invocation context compaction configuration and token estimation.
2//!
3//! This module provides [`IntraCompactionConfig`] for configuring mid-invocation
4//! compaction and [`estimate_tokens`](crate::intra_compaction::estimate_tokens) for heuristic token counting. Unlike the
5//! existing [`EventsCompactionConfig`](crate::EventsCompactionConfig) which handles
6//! post-invocation compaction based on invocation count, intra-invocation compaction
7//! monitors token usage *during* an invocation and triggers summarization before
8//! each LLM call when the context exceeds a threshold.
9//!
10//! The actual summarization reuses the existing [`BaseEventsSummarizer`](crate::BaseEventsSummarizer)
11//! trait — this module only provides the config and token estimator.
12
13use crate::Event;
14use crate::Part;
15
16/// Configuration for intra-invocation context compaction.
17///
18/// When attached to a runner, the runner checks `estimate_tokens()` before each
19/// LLM call and triggers summarization via [`BaseEventsSummarizer`](crate::BaseEventsSummarizer)
20/// when the estimated token count exceeds `token_threshold`.
21///
22/// # Example
23///
24/// ```rust
25/// use adk_core::IntraCompactionConfig;
26///
27/// let config = IntraCompactionConfig {
28///     token_threshold: 50_000,
29///     overlap_event_count: 5,
30///     chars_per_token: 4,
31/// };
32/// ```
33#[derive(Debug, Clone)]
34pub struct IntraCompactionConfig {
35    /// Token count threshold that triggers compaction.
36    pub token_threshold: u64,
37    /// Number of recent events to preserve after compaction for continuity.
38    pub overlap_event_count: usize,
39    /// Characters-per-token ratio for estimation (default: 4).
40    pub chars_per_token: u32,
41}
42
43impl Default for IntraCompactionConfig {
44    fn default() -> Self {
45        Self { token_threshold: 100_000, overlap_event_count: 10, chars_per_token: 4 }
46    }
47}
48
49/// Estimate token count from a list of events using a character heuristic.
50///
51/// Sums the character lengths of all text parts and serialized function call/response
52/// actions across all events, then divides by `chars_per_token` (integer division).
53///
54/// # Arguments
55///
56/// * `events` - The conversation events to estimate tokens for.
57/// * `chars_per_token` - The character-to-token ratio (e.g., 4 means ~4 chars per token).
58///
59/// # Returns
60///
61/// Estimated token count. Returns 0 if `chars_per_token` is 0 or events are empty.
62///
63/// # Example
64///
65/// ```rust
66/// use adk_core::intra_compaction::estimate_tokens;
67/// use adk_core::{Event, Content, Part};
68///
69/// let mut event = Event::new("inv-1");
70/// event.set_content(Content::new("user").with_text("Hello, world!"));
71/// let tokens = estimate_tokens(&[event], 4);
72/// assert_eq!(tokens, 3); // 13 chars / 4 = 3
73/// ```
74pub fn estimate_tokens(events: &[Event], chars_per_token: u32) -> u64 {
75    if chars_per_token == 0 {
76        return 0;
77    }
78    let total_chars: u64 = events.iter().map(|e| estimate_event_chars(e) as u64).sum();
79    total_chars / chars_per_token as u64
80}
81
82/// Estimate the character count of a single event.
83///
84/// Counts characters from:
85/// - Text parts (text length)
86/// - Thinking parts (thinking text length)
87/// - Function call parts (serialized args length + name length)
88/// - Function response parts (serialized response length + name length)
89/// - Serialized actions (state_delta as JSON)
90fn estimate_event_chars(event: &Event) -> usize {
91    let mut chars = 0;
92
93    if let Some(content) = &event.llm_response.content {
94        for part in &content.parts {
95            chars += estimate_part_chars(part);
96        }
97    }
98
99    // Count serialized actions (state_delta)
100    if !event.actions.state_delta.is_empty()
101        && let Ok(json) = serde_json::to_string(&event.actions.state_delta)
102    {
103        chars += json.len();
104    }
105
106    chars
107}
108
109/// Estimate the character count of a single content part.
110fn estimate_part_chars(part: &Part) -> usize {
111    match part {
112        Part::Text { text } => text.len(),
113        Part::Thinking { thinking, .. } => thinking.len(),
114        Part::FunctionCall { name, args, .. } => {
115            name.len() + serde_json::to_string(args).map_or(0, |s| s.len())
116        }
117        Part::FunctionResponse { function_response, .. } => {
118            function_response.name.len()
119                + serde_json::to_string(&function_response.response).map_or(0, |s| s.len())
120        }
121        // Binary/file data and server tool calls contribute minimally to text token count
122        Part::InlineData { .. } | Part::FileData { .. } => 0,
123        Part::ServerToolCall { server_tool_call } => {
124            serde_json::to_string(server_tool_call).map_or(0, |s| s.len())
125        }
126        Part::ServerToolResponse { server_tool_response } => {
127            serde_json::to_string(server_tool_response).map_or(0, |s| s.len())
128        }
129        Part::EmbeddedResource { resource } => {
130            serde_json::to_string(resource).map_or(0, |s| s.len())
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::{Content, FunctionResponseData};
139
140    #[test]
141    fn test_default_config() {
142        let config = IntraCompactionConfig::default();
143        assert_eq!(config.token_threshold, 100_000);
144        assert_eq!(config.overlap_event_count, 10);
145        assert_eq!(config.chars_per_token, 4);
146    }
147
148    #[test]
149    fn test_estimate_tokens_empty() {
150        assert_eq!(estimate_tokens(&[], 4), 0);
151    }
152
153    #[test]
154    fn test_estimate_tokens_zero_ratio() {
155        let mut event = Event::new("inv-1");
156        event.set_content(Content::new("user").with_text("Hello"));
157        assert_eq!(estimate_tokens(&[event], 0), 0);
158    }
159
160    #[test]
161    fn test_estimate_tokens_text_only() {
162        let mut event = Event::new("inv-1");
163        // "Hello" = 5 chars, 5 / 4 = 1
164        event.set_content(Content::new("user").with_text("Hello"));
165        assert_eq!(estimate_tokens(&[event], 4), 1);
166    }
167
168    #[test]
169    fn test_estimate_tokens_multiple_events() {
170        let mut e1 = Event::new("inv-1");
171        e1.set_content(Content::new("user").with_text("Hello")); // 5 chars
172        let mut e2 = Event::new("inv-1");
173        e2.set_content(Content::new("model").with_text("World!")); // 6 chars
174        // Total: 11 chars / 4 = 2
175        assert_eq!(estimate_tokens(&[e1, e2], 4), 2);
176    }
177
178    #[test]
179    fn test_estimate_tokens_with_function_call() {
180        let mut event = Event::new("inv-1");
181        event.llm_response.content = Some(Content {
182            role: "model".to_string(),
183            parts: vec![Part::FunctionCall {
184                name: "get_weather".to_string(),
185                args: serde_json::json!({"city": "NYC"}),
186                id: None,
187                thought_signature: None,
188            }],
189        });
190        let tokens = estimate_tokens(&[event], 4);
191        // "get_weather" = 11 chars + {"city":"NYC"} serialized
192        assert!(tokens > 0);
193    }
194
195    #[test]
196    fn test_estimate_tokens_with_function_response() {
197        let mut event = Event::new("inv-1");
198        event.llm_response.content = Some(Content {
199            role: "function".to_string(),
200            parts: vec![Part::FunctionResponse {
201                function_response: FunctionResponseData::new(
202                    "get_weather",
203                    serde_json::json!({"temp": 72}),
204                ),
205                id: None,
206                annotations: None,
207            }],
208        });
209        let tokens = estimate_tokens(&[event], 4);
210        assert!(tokens > 0);
211    }
212}