loopctl 0.2.1

A trait-based framework for building agent loops with pluggable LLM clients, tools, and memory
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
//! Supporting types for context compaction.
//!
//! Data types used across the compaction pipeline:
//!
//! - [`CompactReason`] — why compaction was triggered.
//! - [`CompactionContext`] — input metadata passed to compactors.
//! - [`CompactionOutcome`] — result of a single compaction pass.
//! - [`CompactTelemetry`] — telemetry data for compaction operations.
//! - [`PreCompactStats`] / [`PostCompactStats`] — stats before/after compaction.
//! - [`ContextOverflow`] — error when the conversation cannot fit.
//! - [`EnsureContextResult`] — result of [`ContextManager::ensure_context_fits`](super::ContextManager::ensure_context_fits).

use crate::compact::TokenCounter;
use crate::message::Message;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::sync::Arc;

/// Why compaction was triggered.
///
/// Different triggers may warrant different compaction strategies.
/// For example, an [`Emergency`](CompactReason::Emergency) compaction
/// should be more aggressive than a routine threshold check.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CompactReason {
    /// Token usage exceeded the configured threshold percentage.
    ///
    /// This is the routine, expected trigger: estimated context size crossed the
    /// [`ContextManager`](super::ContextManager)'s threshold (80% by default),
    /// so a compaction pass runs proactively before the next turn to keep the
    /// context comfortably below the window.
    ThresholdExceeded,

    /// Token usage is dangerously close to the context window limit.
    ///
    /// This is the fallback safety trigger, firing when usage reaches the
    /// emergency zone (95% of the window) regardless of the configured
    /// threshold. An emergency compaction should compact more aggressively
    /// than a routine threshold pass because the conversation is on the verge
    /// of overflowing the model's window.
    Emergency,

    /// Compaction was explicitly requested (e.g. by the agent or a tool).
    ///
    /// Compaction was forced by an explicit caller rather than by a size-based
    /// trigger — for example a host application compacting on demand, or a tool
    /// that wants to free context before producing a large result.
    Manual,
}

impl fmt::Display for CompactReason {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::ThresholdExceeded => write!(f, "threshold exceeded"),
            Self::Emergency => write!(f, "emergency"),
            Self::Manual => write!(f, "manual"),
        }
    }
}

/// Metadata passed to [`ContextCompactor::compact`](super::ContextCompactor::compact)
/// describing the compaction trigger and current state.
///
/// Compactors can use this information to decide how aggressively to
/// compact — e.g. an emergency compaction may use more aggressive
/// summarization than a routine threshold check.
#[derive(Clone)]
pub struct CompactionContext {
    /// Estimated token count before compaction.
    ///
    /// The compactor's input size, computed by the configured
    /// [`TokenCounter`]. Compaction aims to bring the post-compaction size
    /// below this so the conversation fits with headroom for the next turn.
    pub tokens_before: u64,

    /// Why compaction was triggered.
    ///
    /// Compactors may use the trigger to pick a strategy — an
    /// [`Emergency`](CompactReason::Emergency) trigger warrants more aggressive
    /// summarization than a routine [`ThresholdExceeded`](CompactReason::ThresholdExceeded).
    pub reason: CompactReason,

    /// The model's context window size.
    ///
    /// The hard upper bound on tokens the model accepts in one request. This is
    /// the denominator every compaction threshold and target is expressed
    /// against, so the compactor can decide how much to keep.
    pub context_window: u64,

    /// The current turn number in the session.
    ///
    /// Zero-indexed within the run. Useful for compaction strategies that weight
    /// recent turns more heavily, or for correlating a compaction pass back to
    /// the turn that triggered it in logs.
    pub turn: usize,

    /// The token counter for estimating message sizes.
    ///
    /// The same counter the driver uses for its compaction trigger — so the
    /// compactor can self-report `tokens_after` consistently. Compact
    /// implementations should use `context.counter.count(&messages)` instead
    /// of the static [`CompactionOutcome::estimate_tokens`] to match the
    /// driver's configured counter.
    pub counter: Arc<dyn TokenCounter>,
}

/// Result of a single compaction pass.
///
/// Returned by [`ContextCompactor::compact`](super::ContextCompactor::compact),
/// this struct contains the compacted message list along with telemetry data
/// about what happened.
#[derive(Debug, Clone)]
pub struct CompactionOutcome {
    /// The compacted message list.
    ///
    /// The messages that remain after compaction — typically a summary or
    /// truncation of the original conversation. The caller feeds this list back
    /// into the loop as the new history. May equal the input when compaction
    /// decided no change was needed (see [`CompactionOutcome::no_change`]).
    pub messages: Vec<Message>,

    /// Estimated token count after compaction.
    ///
    /// The post-compaction size of [`messages`](Self::messages), estimated with
    /// the same heuristic used for the pre-compaction count, so before/after
    /// values are directly comparable.
    pub tokens_after: u64,

    /// Estimated tokens saved by compaction.
    ///
    /// The difference between the pre-compaction token count and
    /// [`tokens_after`](Self::tokens_after). Zero when compaction made no change
    /// or when it enlarged the conversation (e.g. injecting a summary that
    /// outweighs the messages it replaced).
    pub tokens_saved: u64,

    /// Whether compaction succeeded.
    ///
    /// `true` when the compactor produced a usable message list, even if that
    /// list is unchanged. `false` only when the compactor itself failed — in
    /// that case [`error`](Self::error) describes the failure and
    /// [`messages`](Self::messages) typically holds the original input.
    pub success: bool,

    /// Error message if compaction failed.
    ///
    /// `Some(description)` when [`success`](Self::success) is `false`, carrying
    /// the compactor's human-readable failure reason. `None` on success. Typed
    /// as a [`String`] because it surfaces to observers/logs, not to programmatic
    /// control flow (the loop treats any failed compaction uniformly).
    pub error: Option<String>,
}

impl CompactionOutcome {
    /// Create an outcome representing no change (compaction was not needed).
    ///
    /// Use this when the compactor decides the messages don't need
    /// compaction — e.g. when the message count is below the minimum.
    #[must_use]
    pub fn no_change(messages: Vec<Message>) -> Self {
        let tokens = Self::estimate_tokens(&messages);
        Self {
            messages,
            tokens_after: tokens,
            tokens_saved: 0,
            success: true,
            error: None,
        }
    }

    /// Create an outcome representing successful compaction.
    ///
    /// Computes [`tokens_saved`](Self::tokens_saved) automatically from the
    /// difference between `tokens_before` and `tokens_after`.
    #[must_use]
    pub fn compacted(messages: Vec<Message>, tokens_before: u64, tokens_after: u64) -> Self {
        Self {
            tokens_saved: tokens_before.saturating_sub(tokens_after),
            messages,
            tokens_after,
            success: true,
            error: None,
        }
    }

    /// Estimate the token count for a slice of messages.
    ///
    /// Convenience static method for compactor implementations that need to
    /// self-report token counts. Uses the default
    /// [`HeuristicTokenCounter`](super::HeuristicTokenCounter). The
    /// [`ContextManager`](super::ContextManager) re-counts the result with
    /// its own configured counter after compaction, so the self-reported
    /// value is a hint — only the manager's count is authoritative for the
    /// before/after comparison.
    #[must_use]
    pub fn estimate_tokens(messages: &[Message]) -> u64 {
        use super::TokenCounter;
        super::HeuristicTokenCounter.count(messages)
    }
}

/// Telemetry data for a single compaction operation.
///
/// Produced by [`ContextManager::ensure_context_fits`](super::ContextManager::ensure_context_fits)
/// when compaction occurs. Observers receive this via
/// [`on_compaction`](crate::observer::LoopObserver::on_compaction).
#[derive(Debug, Clone)]
pub struct CompactTelemetry {
    /// Why compaction was triggered.
    ///
    /// The [`CompactReason`] that caused this pass, useful for distinguishing
    /// routine threshold compactions from emergency or manual ones when reading
    /// the telemetry.
    pub trigger: CompactReason,

    /// Conversation stats before compaction.
    ///
    /// A snapshot of the conversation as it was when compaction began — message
    /// counts broken down by role and token estimate. See [`PreCompactStats`].
    pub pre_compact: PreCompactStats,

    /// Conversation stats after compaction.
    ///
    /// A snapshot of the conversation after compaction completed, plus the
    /// savings achieved. Compare against [`pre_compact`](Self::pre_compact) to
    /// measure the effect of the pass. See [`PostCompactStats`].
    pub post_compact: PostCompactStats,

    /// Wall-clock duration of the compaction.
    ///
    /// Time spent inside the compactor's `compact` call for this pass, measured
    /// from just before the call to just after. Excludes the token-estimate
    /// bookkeeping done before and after the call itself.
    pub duration: std::time::Duration,
}

/// Conversation statistics captured before compaction.
///
/// A breakdown of the conversation's shape at the moment compaction begins:
/// how many messages there are, their estimated token cost, and how they split
/// across user/assistant/tool roles. Captured by
/// [`ContextManager::build_telemetry`](super::ContextManager::build_telemetry)
/// and bundled into [`CompactTelemetry::pre_compact`].
#[derive(Debug, Clone)]
pub struct PreCompactStats {
    /// Total number of messages in the conversation.
    ///
    /// Every message in the history about to be compacted, regardless of role
    /// or content. This is the input size the compactor operates on.
    pub total_messages: usize,

    /// Estimated token count.
    ///
    /// The pre-compaction token estimate of the whole conversation, using the
    /// standard 4-chars-per-token heuristic. This is the number compared against
    /// the threshold to decide whether compaction was needed.
    pub estimated_tokens: u64,

    /// Number of user-role messages.
    ///
    /// Messages whose role is [`User`](crate::message::Role::User). Includes
    /// both genuine user turns and tool-result messages, which are conventionally
    /// sent with the user role.
    pub user_messages: usize,

    /// Number of assistant-role messages.
    ///
    /// Messages whose role is [`Assistant`](crate::message::Role::Assistant) —
    /// the model's own responses, including any that carried tool-call requests.
    pub assistant_messages: usize,

    /// Number of messages containing tool calls or results.
    ///
    /// Messages with at least one tool-call or tool-result part, regardless of
    /// role. These are often worth preserving across compaction because they
    /// carry the intermediate state of the tool loop.
    pub tool_messages: usize,
}

/// Conversation statistics captured after compaction.
///
/// A summary of the conversation's shape after compaction completes, together
/// with how much the pass reclaimed. Captured by
/// [`ContextManager::build_telemetry`](super::ContextManager::build_telemetry)
/// and bundled into [`CompactTelemetry::post_compact`].
#[derive(Debug, Clone)]
pub struct PostCompactStats {
    /// Total number of messages after compaction.
    ///
    /// The size of the compacted message list. Smaller than the pre-compaction
    /// [`total_messages`](PreCompactStats::total_messages) when compaction
    /// removed or summarized messages; equal when it made no change.
    pub total_messages: usize,

    /// Estimated token count after compaction.
    ///
    /// The post-compaction token estimate, comparable to
    /// [`estimated_tokens`](PreCompactStats::estimated_tokens) from the
    /// pre-compaction snapshot. The difference is [`tokens_saved`](Self::tokens_saved).
    pub estimated_tokens: u64,

    /// Tokens removed by compaction.
    ///
    /// How many tokens the pass reclaimed: the pre-compaction estimate minus
    /// the post-compaction estimate. Saturates at zero, so it never goes
    /// negative even if a summary injection made the conversation larger.
    pub tokens_saved: u64,

    /// Percentage of tokens saved (0–100).
    ///
    /// [`tokens_saved`](Self::tokens_saved) as a share of the pre-compaction
    /// estimate, expressed as a whole-number percentage. Clamped to `0..=100`;
    /// `0` when nothing was saved or when the pre-compaction estimate was zero.
    pub percent_saved: u8,
}

/// Error returned when the conversation cannot fit within the context
/// window, even after compaction.
///
/// Terminal condition — the conversation is too large and the
/// compactor was unable to reduce it sufficiently.
#[derive(Debug, Clone)]
pub struct ContextOverflow {
    /// Estimated token count of the conversation.
    ///
    /// How many tokens the conversation occupies when it overflows — the same
    /// heuristic estimate used everywhere else in the subsystem. Compare
    /// against [`context_window`](Self::context_window) (or use
    /// [`overflow`](Self::overflow)) to see by how much it exceeded the limit.
    pub tokens_used: u64,

    /// The model's context window size.
    ///
    /// The hard token limit the conversation failed to fit under, even after a
    /// compaction pass. The denominator [`utilization`](Self::utilization) is
    /// measured against.
    pub context_window: u64,

    /// How many messages were in the conversation.
    ///
    /// The message count at the point of overflow, useful for diagnosing
    /// whether the overflow came from many small messages or a few large ones.
    pub message_count: usize,

    /// The reason compaction was attempted.
    ///
    /// The [`CompactReason`] that triggered the (failed) compaction attempt.
    /// An [`Emergency`](CompactReason::Emergency) trigger here means even an
    /// aggressive compaction could not bring the conversation back under the
    /// window.
    pub trigger: CompactReason,

    /// Error from the compactor, if compaction was attempted.
    ///
    /// `Some(description)` when a compactor ran but returned an error that
    /// prevented recovery; `None` when the conversation was simply too large
    /// to reduce (compaction succeeded but the result still overflowed).
    pub compactor_error: Option<String>,
}

impl ContextOverflow {
    /// How many tokens the conversation exceeds the context window by.
    ///
    /// Returns zero when the conversation fits within the window. Uses
    /// saturating subtraction so an underflow never panics.
    #[must_use]
    pub fn overflow(&self) -> u64 {
        self.tokens_used.saturating_sub(self.context_window)
    }

    /// The fraction of the context window currently consumed.
    ///
    /// Returns a value between `0.0` and `1.0` when the conversation fits,
    /// or above `1.0` when it overflows. Returns infinity when the context
    /// window is zero (division by zero).
    #[must_use]
    pub fn utilization(&self) -> f64 {
        if self.context_window == 0 {
            return f64::INFINITY;
        }
        crate::numeric::unit_ratio(self.tokens_used, self.context_window)
    }
}

impl fmt::Display for ContextOverflow {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "context overflow: {} tokens used of {} window ({} messages, {} overflow)",
            self.tokens_used,
            self.context_window,
            self.message_count,
            self.overflow()
        )
    }
}

impl std::error::Error for ContextOverflow {}

/// Result of [`ContextManager::ensure_context_fits`](super::ContextManager::ensure_context_fits).
///
/// Tells the caller whether compaction occurred and provides the
/// (possibly compacted) message list.
#[derive(Debug, Clone)]
pub enum EnsureContextResult {
    /// Compaction occurred and produced a shorter message list.
    ///
    /// The wrapped [`CompactionOutcome`] carries the compacted messages, the
    /// token savings, and whether the pass succeeded. Feed
    /// [`outcome.messages`](CompactionOutcome::messages) back into the loop as
    /// the new history.
    Compacted(CompactionOutcome),

    /// No compaction was needed; messages returned as-is.
    ///
    /// The conversation fit comfortably within the threshold, so no compaction
    /// pass ran. The wrapped message list is the original input, unchanged; use
    /// it directly as the next-turn history.
    NoAction(Vec<Message>),
}

impl EnsureContextResult {
    /// Extract the message list from this result, regardless of variant.
    ///
    /// Returns the compacted messages from [`Compacted`](Self::Compacted) or
    /// the unchanged messages from [`NoAction`](Self::NoAction). Use this when
    /// you only care about the resulting history and not whether compaction
    /// actually occurred.
    #[must_use]
    pub fn into_messages(self) -> Vec<Message> {
        match self {
            Self::Compacted(outcome) => outcome.messages,
            Self::NoAction(messages) => messages,
        }
    }
}