meerkat-core 0.8.14

Core agent logic for Meerkat (no I/O deps)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
//! Compactor trait — provider-agnostic context compaction.
//!
//! The `Compactor` trait defines how and when to compact (summarize) the
//! conversation history to reclaim context window space. Implementations
//! live in `meerkat-session` (behind the `session-compaction` feature).

use crate::types::Message;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};

/// Metadata key used to persist compaction cadence across session reuse.
pub const SESSION_COMPACTION_CADENCE_KEY: &str = "session_compaction_cadence";

/// Durable session-scoped cadence state for compaction decisions.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct SessionCompactionCadence {
    /// Monotonic index of pre-LLM boundaries seen in this session.
    pub session_boundary_index: u64,
    /// Boundary index where compaction last completed successfully.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_compaction_boundary_index: Option<u64>,
    /// Boundary index where compaction was last attempted, successful or not.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_compaction_attempt_boundary_index: Option<u64>,
}

/// Context provided to `Compactor::should_compact` for trigger decisions.
#[derive(Debug, Clone)]
pub struct CompactionContext {
    /// Input token count from the last LLM response.
    pub last_input_tokens: u64,
    /// Total number of messages in the session.
    pub message_count: usize,
    /// Estimated history tokens (JSON bytes / 4).
    pub estimated_history_tokens: u64,
    /// Estimated serialized size in bytes of the transcript as an LLM
    /// request (content byte lengths, inline media payloads included, times
    /// a documented safety factor — see
    /// `crate::agent::compact::estimate_request_bytes`).
    ///
    /// Providers cap requests in BYTES, not tokens. A byte-heavy/token-light
    /// transcript (2026-07-29 household incident: inline media pushed a
    /// transcript past Anthropic's request-size cap, failing every turn with
    /// `request_too_large` while the token trigger stayed far below its
    /// threshold) must be visible to trigger decisions in the same unit the
    /// provider enforces.
    pub estimated_request_bytes: u64,
    /// Exact provider-lowered request-body pressure, when the active client can
    /// prove it. This is measured after ordered System-message projection,
    /// blob hydration, tool selection, provider-parameter resolution, replay
    /// projection, and provider-native JSON lowering.
    ///
    /// Custom clients default to `None`: an unavailable witness is truthful,
    /// while relabeling the transcript estimate as exact would make the
    /// compaction safety decision unsound.
    pub provider_request_pressure: Option<ProviderRequestPressure>,
    /// Session-scoped pre-LLM boundary index used by the cadence guard.
    ///
    /// This is the latest successful compaction boundary or failed compaction
    /// attempt boundary, whichever is newer.
    pub last_compaction_boundary_index: Option<u64>,
    /// Current session-scoped pre-LLM boundary index.
    pub session_boundary_index: u64,
}

/// Provider-authored witness for the exact JSON body an invocation may send.
///
/// `encoded_bytes` is the largest serialized JSON body among all request
/// bodies the invocation can issue. This matters for clients such as OpenAI
/// Responses, where a continuation request may fall back to a full replay.
/// `max_bytes` is the active provider's conservative request-body cap, when
/// known. Both values are recomputed through the currently active fallback
/// candidate for every model boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderRequestPressure {
    pub encoded_bytes: u64,
    pub max_bytes: Option<u64>,
}

impl ProviderRequestPressure {
    pub fn new(encoded_bytes: u64, max_bytes: Option<u64>) -> Self {
        Self {
            encoded_bytes,
            max_bytes,
        }
    }

    /// Effective request-body cap after combining provider and host policy.
    ///
    /// Either authority may be stricter. A configured cap is not allowed to
    /// widen the provider's real limit, and an inferred provider limit is not
    /// allowed to widen an explicit deployment policy.
    pub fn effective_cap(self, configured_cap: Option<u64>) -> Option<u64> {
        match (configured_cap, self.max_bytes) {
            (Some(configured), Some(provider)) => Some(configured.min(provider)),
            (Some(configured), None) => Some(configured),
            (None, Some(provider)) => Some(provider),
            (None, None) => None,
        }
    }

    /// Four-fifths of the effective request-size cap.
    pub fn trigger_threshold(self, configured_cap: Option<u64>) -> Option<u64> {
        self.effective_cap(configured_cap).map(|cap| {
            cap.saturating_mul(REQUEST_BYTE_TRIGGER_NUMERATOR) / REQUEST_BYTE_TRIGGER_DENOMINATOR
        })
    }
}

/// Result of a compaction rebuild.
#[derive(Debug, Clone)]
pub struct CompactionResult {
    /// The rebuilt message history (summary + retained recent turns).
    pub messages: Vec<Message>,
    /// The one runtime-validated summary inserted into the rebuilt transcript.
    ///
    /// This mapping is explicit so validation never infers the summary from an
    /// otherwise-unowned rebuilt slot. Public compactors must identify the
    /// exact typed summary message and its rebuilt offset.
    pub summary: CompactionSummary,
    /// Source messages retained in `messages`, paired with exact source and
    /// rebuilt offsets.
    ///
    /// Retained provenance is explicit rather than inferred by value. Message
    /// values are not unique, so a value-only diff cannot prove which copy of
    /// a duplicate was removed and which copy remains authoritative.
    pub retained: Vec<CompactionRetained>,
    /// Messages removed from history, paired with their canonical offsets in
    /// the pre-compaction transcript.
    ///
    /// A compactor must not return a discard-slice-local index: provenance is
    /// interpreted against the full [`CompactionWindow::messages`] history and
    /// validated before any memory projection is written.
    pub discarded: Vec<CompactionDiscard>,
}

/// A compactor-proposed summary and its exact rebuilt-transcript slot.
///
/// The core runtime validates this mapping, the typed role, canonical content,
/// and chronology before any transcript or memory projection is committed.
#[derive(Debug, Clone, PartialEq)]
pub struct CompactionSummary {
    /// Offset of `message` in the rebuilt transcript.
    pub rebuilt_offset: u64,
    /// Typed compaction-summary message inserted by the rebuild.
    pub message: Message,
}

impl CompactionSummary {
    /// Bind the inserted summary to its rebuilt-transcript slot.
    pub fn new(rebuilt_offset: u64, message: Message) -> Self {
        Self {
            rebuilt_offset,
            message,
        }
    }
}

/// Canonical rendered prefix for the typed compaction-summary boundary.
///
/// Keeping this in the core contract lets the validator prove that the one
/// inserted message contains the exact summary produced for this attempt.
pub const COMPACTION_SUMMARY_PREFIX: &str = "\
[Context compacted] A previous context produced the following summary of work so far. \
The current tool and session state is preserved. Use this summary to continue without \
duplicating work:\n\n";

/// One message removed by compaction plus its source transcript offset.
///
/// The source offset is part of the compactor contract because reconstructing
/// it from the returned discard slice loses retained messages outside the
/// discarded region and cannot represent non-contiguous custom compaction
/// strategies without guessing.
#[derive(Debug, Clone, PartialEq)]
pub struct CompactionDiscard {
    /// Offset of `message` in the full pre-compaction transcript.
    pub source_offset: u64,
    /// Message removed from the active transcript.
    pub message: Message,
}

impl CompactionDiscard {
    /// Bind a discarded message to its canonical pre-compaction offset.
    pub fn new(source_offset: u64, message: Message) -> Self {
        Self {
            source_offset,
            message,
        }
    }
}

/// One source message retained by compaction and its rebuilt-transcript slot.
///
/// Together, [`CompactionResult::retained`] and
/// [`CompactionResult::discarded`] must partition the full source transcript.
/// Retained rebuilt offsets must identify the same message in
/// [`CompactionResult::messages`]. This explicit mapping makes removal
/// provenance exact even when multiple source messages have identical values.
#[derive(Debug, Clone, PartialEq)]
pub struct CompactionRetained {
    /// Offset of `message` in the full pre-compaction transcript.
    pub source_offset: u64,
    /// Offset of `message` in the rebuilt transcript.
    pub rebuilt_offset: u64,
    /// Message retained from the source transcript.
    pub message: Message,
}

impl CompactionRetained {
    /// Bind one retained source message to its rebuilt-transcript slot.
    pub fn new(source_offset: u64, rebuilt_offset: u64, message: Message) -> Self {
        Self {
            source_offset,
            rebuilt_offset,
            message,
        }
    }
}

/// Fraction of `CompactionConfig::max_request_bytes` at which the byte-aware
/// trigger fires, mirroring the model-aware token default (4/5 of the model
/// context window). The remaining 1/5 is headroom for the active turn's new
/// content plus request components the estimate cannot see (tool schemas,
/// provider parameters).
const REQUEST_BYTE_TRIGGER_NUMERATOR: u64 = 4;
const REQUEST_BYTE_TRIGGER_DENOMINATOR: u64 = 5;

/// Configuration for the default compactor implementation.
#[derive(Debug, Clone)]
pub struct CompactionConfig {
    /// Compaction triggers when `last_input_tokens >= auto_compact_threshold`.
    pub auto_compact_threshold: u64,
    /// Provider request-size cap in bytes, when known.
    ///
    /// `None` disables the byte-aware trigger (pre-incident behavior). When
    /// set, compaction also triggers when the estimated serialized request
    /// size crosses [`CompactionConfig::request_byte_trigger_threshold`]
    /// (4/5 of this cap). The token trigger alone missed the 2026-07-29
    /// household incident: providers cap requests in bytes, and byte-heavy /
    /// token-light content (inline media) crosses the byte cap first, after
    /// which every turn fails terminally with `request_too_large`.
    pub max_request_bytes: Option<u64>,
    /// Number of recent complete turns to retain after compaction.
    pub recent_turn_budget: usize,
    /// Maximum tokens for the compaction summary LLM response.
    pub max_summary_tokens: u32,
    /// Minimum session-scoped LLM boundaries between consecutive compactions.
    pub min_turns_between_compactions: u32,
}

impl CompactionConfig {
    /// Effective byte-trigger threshold: 4/5 of `max_request_bytes`, or
    /// `None` when no request-size cap is configured.
    ///
    /// This is the one owner of the byte-trigger arithmetic so every
    /// compactor evaluates the same fraction of the same cap.
    pub fn request_byte_trigger_threshold(&self) -> Option<u64> {
        self.max_request_bytes.map(|cap| {
            cap.saturating_mul(REQUEST_BYTE_TRIGGER_NUMERATOR) / REQUEST_BYTE_TRIGGER_DENOMINATOR
        })
    }
}

impl Default for CompactionConfig {
    fn default() -> Self {
        Self {
            auto_compact_threshold: 100_000,
            max_request_bytes: None,
            recent_turn_budget: 4,
            max_summary_tokens: 4096,
            min_turns_between_compactions: 3,
        }
    }
}

/// Provider-agnostic compaction strategy.
///
/// Determines when to compact and how to rebuild the history after summarization.
pub trait Compactor: Send + Sync {
    /// Check whether compaction should run given the current context.
    fn should_compact(&self, ctx: &CompactionContext) -> bool;

    /// Effective hard request-body cap for post-compaction fit checks.
    ///
    /// Custom compactors inherit the provider witness. Implementations with a
    /// stricter host-configured cap override this so trigger and fit semantics
    /// use the same authority.
    fn request_byte_cap(&self, pressure: ProviderRequestPressure) -> Option<u64> {
        pressure.max_bytes
    }

    /// Return the prompt to send to the LLM for summarization.
    fn compaction_prompt(&self) -> &str;

    /// Maximum tokens the summarization response may consume.
    fn max_summary_tokens(&self) -> u32;

    /// Prepare messages for the summarization LLM call.
    ///
    /// Called before sending the history to the LLM for summarization.
    /// Implementations may strip content that is not suitable for the
    /// summarization pass (e.g. base64-encoded images).
    ///
    /// The default implementation returns an unmodified clone.
    fn prepare_for_summarization(&self, messages: &[Message]) -> Vec<Message> {
        messages.to_vec()
    }

    /// Rebuild the session history from a summary and current messages.
    ///
    /// The implementation should:
    /// 1. Preserve every ordered `Message::System` verbatim and in relative
    ///    source order.
    /// 2. Inject a summary message.
    /// 3. Retain recent complete turns per `recent_turn_budget`.
    /// 4. Return retained source messages as `retained`, with their offsets in
    ///    both the full source slice and rebuilt history.
    /// 5. Return everything else as `discarded`, with each message's offset in
    ///    the full `messages` slice.
    ///
    /// `retained` and `discarded` must partition the source transcript. The
    /// `summary` must identify the one injected message at the rebuilt position
    /// of the first discarded source row: every retained source row before that
    /// boundary remains before the summary, and every retained source row after
    /// it remains after the summary. Its typed role and text must match the
    /// exact summary supplied to this method. At least one source message must
    /// be discarded, and the rebuild must not grow the transcript.
    fn rebuild_history(&self, messages: &[Message], summary: &str) -> CompactionResult;
}

/// Borrowed view of the compaction inputs handed to a [`CompactionCurator`].
///
/// This is exactly the data the agent-loop compaction flow already holds when
/// it would otherwise run the summarization LLM call: the full current
/// transcript (including every ordered System message and any prior typed
/// compaction-summary user message), the last observed input token count, and
/// the session-scoped boundary index at which compaction runs.
#[derive(Debug, Clone, Copy)]
pub struct CompactionWindow<'a> {
    /// Full current transcript messages.
    pub messages: &'a [Message],
    /// Input token count from the last LLM response.
    pub last_input_tokens: u64,
    /// Session-scoped pre-LLM boundary index at which compaction runs.
    pub session_boundary_index: u64,
}

/// Validated non-empty summary text produced by a [`CompactionCurator`].
///
/// The fallible constructor is the only way to mint a value, so a curated
/// summary can never smuggle an empty string past the typed contract and
/// re-create the empty-summary failure mode downstream.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CuratedCompactionSummary(String);

impl CuratedCompactionSummary {
    /// Construct from summary text; whitespace-only text is rejected.
    pub fn new(text: impl Into<String>) -> Result<Self, CompactionCuratorError> {
        let text = text.into();
        if text.trim().is_empty() {
            return Err(CompactionCuratorError::EmptySummary);
        }
        Ok(Self(text))
    }

    /// The summary text.
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Consume into the owned summary text.
    pub fn into_string(self) -> String {
        self.0
    }
}

/// Errors from a host-supplied compaction curator.
#[derive(Debug, thiserror::Error)]
pub enum CompactionCuratorError {
    /// The curator produced an empty summary, so there is nothing to commit.
    #[error("curator produced an empty compaction summary")]
    EmptySummary,
    /// The curator failed to produce a summary.
    #[error("curator failed to produce a compaction summary: {0}")]
    Failed(String),
}

/// Host-supplied compaction summary curation.
///
/// When configured on the agent, the compaction flow asks the curator to
/// produce the summary text INSTEAD of running the summarization LLM call
/// (`prepare_for_summarization` and the client `stream_response` call are
/// skipped entirely; the recorded summary usage is zero).
///
/// The compaction TRIGGER stays machine-emitted (`CheckCompaction`) and gated
/// by [`Compactor::should_compact`]; the curator substitutes summary CONTENT
/// production only. There is no LLM fallback: a failing curator surfaces a
/// typed `CompactionFailed` event and the original history is preserved.
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait CompactionCurator: Send + Sync {
    /// Produce the compaction summary for the supplied window.
    async fn curate_summary(
        &self,
        window: CompactionWindow<'_>,
    ) -> Result<CuratedCompactionSummary, CompactionCuratorError>;
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    #[test]
    fn curated_compaction_summary_rejects_empty_text() {
        assert!(matches!(
            CuratedCompactionSummary::new(""),
            Err(CompactionCuratorError::EmptySummary)
        ));
        assert!(matches!(
            CuratedCompactionSummary::new("   \n\t"),
            Err(CompactionCuratorError::EmptySummary)
        ));
    }

    #[test]
    fn curated_compaction_summary_round_trips_text() {
        let summary = CuratedCompactionSummary::new("curated summary").unwrap();
        assert_eq!(summary.as_str(), "curated summary");
        assert_eq!(summary.into_string(), "curated summary");
    }

    #[test]
    fn request_byte_trigger_threshold_is_four_fifths_of_the_cap() {
        let config = CompactionConfig {
            max_request_bytes: Some(10_000_000),
            ..CompactionConfig::default()
        };
        assert_eq!(config.request_byte_trigger_threshold(), Some(8_000_000));

        // Default None keeps the byte trigger disabled (pre-incident behavior).
        assert_eq!(
            CompactionConfig::default().request_byte_trigger_threshold(),
            None
        );
    }

    #[test]
    fn provider_and_configured_request_caps_compose_by_minimum() {
        let pressure = ProviderRequestPressure::new(1, Some(8_000_000));
        assert_eq!(pressure.effective_cap(Some(10_000_000)), Some(8_000_000));
        assert_eq!(pressure.effective_cap(Some(6_000_000)), Some(6_000_000));
        assert_eq!(
            pressure.trigger_threshold(Some(10_000_000)),
            Some(6_400_000)
        );
        assert_eq!(pressure.trigger_threshold(Some(6_000_000)), Some(4_800_000));
        assert_eq!(
            ProviderRequestPressure::new(1, None).effective_cap(Some(6_000_000)),
            Some(6_000_000)
        );
        assert_eq!(
            ProviderRequestPressure::new(1, Some(8_000_000)).effective_cap(None),
            Some(8_000_000)
        );
    }
}