loopctl 0.3.0

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
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
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
//! Typed context structs for [`LoopObserver`](crate::observer::LoopObserver) callbacks.
//!
//! Each struct carries the relevant fields for a specific lifecycle point.
//! Observers receive shared references (`&Context`) — the structs are
//! notification-only data carriers.

/// Context for [`LoopObserver::on_run_start`](crate::observer::LoopObserver::on_run_start).
///
/// Carries the session identifier so observers can correlate lifecycle
/// events with a specific agent run. One run is one `run()` call on the
/// loop; a session may contain many runs.
#[derive(Debug, Clone)]
pub struct RunStartContext {
    /// Unique session identifier.
    ///
    /// Correlates all lifecycle events belonging to the same agent session.
    /// Stable across every `run()` call on the same loop.
    pub session_id: uuid::Uuid,
}

/// Context for [`LoopObserver::on_run_end`](crate::observer::LoopObserver::on_run_end).
///
/// Captures the run's completion status, optional error description, total
/// turns executed, and wall-clock duration in milliseconds.
#[derive(Debug, Clone)]
pub struct RunEndContext {
    /// Whether the run completed successfully.
    ///
    /// `true` when the run exited normally, `false` on error or cancellation.
    pub success: bool,

    /// Error description, if the run ended due to an error.
    ///
    /// `None` when [`success`](Self::success) is `true`.
    pub error: Option<String>,

    /// Total turns completed during this run.
    ///
    /// Counts only turns that finished; an in-flight turn at the time of
    /// a fatal error is not included.
    pub total_turns: usize,

    /// Run duration in milliseconds.
    ///
    /// Measured wall-clock from [`on_run_start`](crate::observer::LoopObserver::on_run_start)
    /// to [`on_run_end`](crate::observer::LoopObserver::on_run_end).
    pub duration_ms: u64,
}

/// Context for [`LoopObserver::on_turn_start`](crate::observer::LoopObserver::on_turn_start).
///
/// Provides the turn number and the user query that initiated it.
#[derive(Debug, Clone)]
pub struct TurnStartContext {
    /// Turn number (0-indexed).
    ///
    /// Monotonically increasing within a session; resets on session restart.
    pub turn: usize,

    /// The user query that initiated this turn.
    ///
    /// Contains the full text of the latest user message added to the
    /// conversation before the turn began.
    pub query: String,
}

/// Context for [`LoopObserver::on_turn_end`](crate::observer::LoopObserver::on_turn_end).
///
/// Reports whether the turn succeeded, any error, its duration,
/// and the token counts consumed during the turn.
#[derive(Debug, Clone)]
pub struct TurnEndContext {
    /// Turn number.
    ///
    /// Matches the value passed to the corresponding
    /// [`on_turn_start`](crate::observer::LoopObserver::on_turn_start).
    pub turn: usize,

    /// Whether the turn completed successfully.
    ///
    /// `false` if the turn was interrupted by an error or cancellation.
    pub success: bool,

    /// Error description, if the turn failed.
    ///
    /// `None` when [`success`](Self::success) is `true`.
    pub error: Option<String>,

    /// Wall-clock duration of the turn in milliseconds.
    ///
    /// Measured from [`on_turn_start`](crate::observer::LoopObserver::on_turn_start)
    /// to [`on_turn_end`](crate::observer::LoopObserver::on_turn_end).
    pub duration_ms: u64,

    /// Input tokens consumed this turn.
    ///
    /// Sum of tokens in the prompt sent to the model.
    pub input_tokens: u64,

    /// Output tokens generated this turn.
    ///
    /// Sum of tokens across all assistant responses in the turn,
    /// including intermediate tool-call rounds.
    pub output_tokens: u64,
}

/// Context for [`LoopObserver::on_stream_success`](crate::observer::LoopObserver::on_stream_success).
///
/// Provides the model name and input/output token counts for
/// a successful streaming response.
#[derive(Debug, Clone)]
pub struct StreamContext {
    /// Turn number.
    ///
    /// Identifies which turn this stream response belongs to.
    pub turn: usize,

    /// Model that was streamed.
    ///
    /// The model identifier used for this request, which may differ from
    /// the session default when model fallback occurred.
    pub model: String,

    /// Input tokens consumed.
    ///
    /// Tokens in the prompt sent to the model for this request.
    pub input_tokens: u64,

    /// Output tokens generated.
    ///
    /// Tokens in the model's streamed response.
    pub output_tokens: u64,
}

/// Context for [`LoopObserver::on_stream_failure`](crate::observer::LoopObserver::on_stream_failure).
///
/// Carries the model name and the [`LoopError`](crate::error::LoopError)
/// that caused the streaming failure.
#[derive(Debug, Clone)]
pub struct StreamFailureContext {
    /// Turn number.
    ///
    /// Identifies which turn this failure occurred in.
    pub turn: usize,

    /// Model that failed.
    ///
    /// The model identifier used for the failed request.
    pub model: String,

    /// The error that occurred.
    ///
    /// See [`LoopError`](crate::error::LoopError) for the full set of
    /// failure categories.
    pub error: crate::error::LoopError,
}

/// Context for [`LoopObserver::on_response`](crate::observer::LoopObserver::on_response).
///
/// Contains the model's text response and optional token usage
/// for the turn.
#[derive(Debug, Clone)]
pub struct ResponseContext {
    /// Turn number.
    ///
    /// Identifies which turn produced this response.
    pub turn: usize,

    /// The model's text response.
    ///
    /// Concatenated text content from the assistant message.
    /// Tool-call content is excluded; see [`ToolPostContext`]
    /// for tool result information.
    pub text: String,

    /// Token usage for this turn, if available.
    ///
    /// Populated when the API returns usage data in the
    /// streaming response. `None` when the provider does
    /// not report usage.
    pub usage: Option<crate::stream::Usage>,
}

/// Context for [`LoopObserver::on_text_delta`](crate::observer::LoopObserver::on_text_delta).
///
/// Carries one incremental text chunk from the model's streaming response. Fires
/// once per `IndexedDelta(Text)` event, *during* the stream — as opposed to
/// [`ResponseContext`], which fires once after the whole assistant text is
/// assembled.
///
/// Concatenate `delta` across all `on_text_delta` calls for a given `turn`, in
/// arrival order, to reconstruct the per-turn text. Observers that need a
/// running total maintain their own accumulator; the context carries only the
/// per-chunk slice.
///
/// # Examples
///
/// ```
/// use loopctl::observer::TextDeltaContext;
///
/// let ctx = TextDeltaContext { turn: 0, delta: "Hello".to_string() };
/// assert_eq!(ctx.turn, 0);
/// assert_eq!(ctx.delta, "Hello");
/// ```
#[derive(Debug, Clone)]
pub struct TextDeltaContext {
    /// Turn number (0-indexed).
    ///
    /// Matches the `turn` passed to the surrounding
    /// [`on_turn_start`](crate::observer::LoopObserver::on_turn_start) /
    /// [`on_turn_end`](crate::observer::LoopObserver::on_turn_end) and to
    /// [`ResponseContext::turn`]. Lets observers correlate deltas with the
    /// turn they belong to and detect a fresh turn.
    pub turn: usize,

    /// The incremental text chunk for this delta.
    ///
    /// A small fragment of the assistant's text output. Concatenate in arrival
    /// order per turn to reconstruct the full text. No normalization, no
    /// trimming — what the provider sent, verbatim.
    pub delta: String,
}

/// Context for [`LoopObserver::on_thinking_delta`](crate::observer::LoopObserver::on_thinking_delta).
///
/// Carries one incremental reasoning ("thinking") chunk. Parallel in shape to
/// [`TextDeltaContext`]: same fields, same lifetime semantics (concatenate in
/// arrival order per turn to reconstruct the full reasoning trace). Reasoning
/// is distinct from the assistant's visible text and is never included in
/// [`ResponseContext`].
///
/// # Redacted reasoning
///
/// An empty `delta` signals redacted reasoning (e.g. Anthropic
/// `redacted_thinking`) — the provider withheld the content. Consumers should
/// render a placeholder ("reasoning redacted"), not the empty string.
///
/// # Example
///
/// ```
/// use loopctl::observer::ThinkingDeltaContext;
///
/// let ctx = ThinkingDeltaContext { turn: 0, delta: "considering options…".to_string() };
/// assert_eq!(ctx.turn, 0);
/// assert_eq!(ctx.delta, "considering options…");
/// ```
#[derive(Debug, Clone)]
pub struct ThinkingDeltaContext {
    /// Turn number (0-indexed), matching `on_turn_start` / `on_turn_end`.
    ///
    /// Same value [`TextDeltaContext::turn`] carries for the same turn, so an
    /// observer can interleave thinking and text deltas correctly.
    pub turn: usize,

    /// The incremental reasoning chunk. Concatenate in arrival order per turn.
    ///
    /// Empty string for redacted/encrypted reasoning (e.g. Anthropic
    /// `redacted_thinking`) — render a placeholder, not the empty string.
    pub delta: String,
}

/// Context for [`LoopObserver::on_tool_call_received`](crate::observer::LoopObserver::on_tool_call_received).
///
/// Fired once per tool call after the streaming response has been accumulated
/// and before dispatch of that call begins — strictly earlier than
/// [`ToolPreContext`]. Use this to surface a pending indicator the moment the
/// model decides to call a tool, before execution starts.
///
/// Unlike [`ToolPreContext`], this context also carries the call's `input`,
/// because the input is fully known at accumulation time and downstream
/// consumers may want to render it before execution. It fires exactly once per
/// call regardless of how many recovery retries the call later undergoes.
///
/// # Examples
///
/// ```
/// use loopctl::observer::ToolCallReceivedContext;
///
/// let ctx = ToolCallReceivedContext {
///     turn: 0,
///     tool: "edit".to_string(),
///     call_id: "call_1".to_string(),
///     input: serde_json::json!({"path": "/tmp/a"}),
/// };
/// assert_eq!(ctx.tool, "edit");
/// assert_eq!(ctx.call_id, "call_1");
/// ```
#[derive(Debug, Clone)]
pub struct ToolCallReceivedContext {
    /// Turn number (0-indexed).
    ///
    /// Matches the value passed to the corresponding `on_response` and
    /// `on_tool_pre` for the same assistant message.
    pub turn: usize,

    /// Tool name.
    ///
    /// Matches the name the tool was registered under. Same value as
    /// [`ToolPreContext::tool`] for the same call.
    pub tool: String,

    /// Tool call ID assigned by the API.
    ///
    /// Correlates with [`ToolPreContext::tool_call_id`] for the same call.
    pub call_id: String,

    /// JSON input the model supplied for this call.
    ///
    /// The full input object, as accumulated from the stream. Not present on
    /// `ToolPreContext` (which fires later but omits input).
    pub input: serde_json::Value,
}

/// Context for [`LoopObserver::on_tool_pre`](crate::observer::LoopObserver::on_tool_pre).
///
/// Sent before a tool is executed, providing the tool name and
/// the call ID assigned by the API.
#[derive(Debug, Clone)]
pub struct ToolPreContext {
    /// Turn number.
    ///
    /// Identifies which turn this tool call belongs to.
    pub turn: usize,

    /// Tool name.
    ///
    /// Matches the name the tool was registered under.
    pub tool: String,

    /// Tool call ID from the API response.
    ///
    /// Unique identifier assigned by the model for this specific
    /// tool invocation, used to correlate with the tool result.
    pub tool_call_id: String,
}

/// Context for [`LoopObserver::on_tool_post`](crate::observer::LoopObserver::on_tool_post).
///
/// Sent after a tool completes, providing the tool name, a hash
/// of the output, whether an error occurred, and the execution duration.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ToolPostContext {
    /// The model-issued call id this result answers.
    ///
    /// Mirrors [`ToolPreContext::tool_call_id`](crate::observer::ToolPreContext::tool_call_id)
    /// so observers can pair pre/post events exactly — including
    /// same-tool retries and parallel calls, where the `(turn, tool)`
    /// pair is ambiguous.
    pub tool_call_id: String,

    /// Turn number.
    ///
    /// Identifies which turn this tool result belongs to.
    pub turn: usize,

    /// Tool name.
    ///
    /// Matches the name the tool was registered under.
    pub tool: String,

    /// Deterministic hash of the tool output, if available.
    ///
    /// Used by loop detection to identify repeated tool results
    /// without exposing the full output content.
    pub result_hash: Option<u64>,

    /// Whether the tool returned an error.
    ///
    /// `true` when the tool execution resulted in an error
    /// response rather than a successful output.
    pub is_error: bool,

    /// Wall-clock execution duration.
    ///
    /// Measured from tool dispatch to completion, including any
    /// permission prompts.
    pub duration: std::time::Duration,

    /// Advisory rendering hint forwarded from the tool's
    /// [`ToolOutput`](crate::tool::ToolOutput).
    ///
    /// `None` when the call was blocked before execution (no `ToolOutput`
    /// exists), when the tool set no hint, or on error/panic paths.
    /// Presentation layers read this to pick a render strategy; loop code
    /// never reads it.
    pub display_hint: Option<crate::tool::DisplayHint>,
}

/// Context for [`LoopObserver::on_compaction`](crate::observer::LoopObserver::on_compaction).
///
/// Reports the estimated token counts before and after compaction and the
/// number of tokens saved.
#[derive(Debug, Clone)]
pub struct CompactedContext {
    /// Estimated token count before compaction.
    ///
    /// The payload estimate before the compactor ran — the history plus
    /// the per-request overhead (system prompt, tool schemas) —
    /// reconstructed from `tokens_after + tokens_saved`.
    pub tokens_before: u64,

    /// Estimated token count after compaction.
    ///
    /// The payload estimate of the compacted history plus the same
    /// overhead. Transient context (contributors, retrieved memories)
    /// is not part of it — a retried turn regenerates its own, so
    /// this number does not predict the next request's size when
    /// transients ride it.
    pub tokens_after: u64,

    /// Estimated tokens saved by compaction.
    ///
    /// `tokens_before - tokens_after`, the net reduction achieved by
    /// the compactor.
    pub tokens_saved: u64,
}

/// Context for [`LoopObserver::on_fallback`](crate::observer::LoopObserver::on_fallback).
///
/// Indicates which model failed (`from`) and which replacement
/// model was selected (`to`).
#[derive(Debug, Clone)]
pub struct FallbackContext {
    /// Model that failed.
    ///
    /// The model identifier that produced the error triggering fallback.
    pub from: String,

    /// Replacement model.
    ///
    /// The model identifier that will be used for subsequent requests.
    pub to: String,
}

/// Context for [`LoopObserver::on_model_switched`](crate::observer::LoopObserver::on_model_switched).
///
/// Emitted when the model is hot-swapped via
/// [`BareLoop::switch_model`](crate::engine::BareLoop::switch_model).
/// Carries the previous and new model identifiers.
#[derive(Debug, Clone)]
pub struct ModelSwitchedContext {
    /// Model identifier before the switch.
    ///
    /// The model the session was using up to the point of the hot-swap, so
    /// observers can log or revert the transition.
    pub from: String,

    /// Model identifier after the switch.
    ///
    /// The model that will handle subsequent requests, which may differ in
    /// capability or cost from the previous one.
    pub to: String,
}

/// Context for [`LoopObserver::on_loop_detected`](crate::observer::LoopObserver::on_loop_detected).
///
/// Describes the repeating tool pattern and how many times it
/// was observed.
#[derive(Debug, Clone)]
pub struct LoopDetectedContext {
    /// Description of the repeating tool pattern.
    ///
    /// Human-readable summary of the tool operation(s) that were
    /// detected as repeating.
    pub pattern: String,

    /// Number of times the pattern was observed.
    ///
    /// Counts consecutive repetitions of the same tool operation
    /// with the same result hash.
    pub repetitions: usize,
}

/// Context for [`LoopObserver::on_convergence_detected`](crate::observer::LoopObserver::on_convergence_detected).
///
/// Carries the configured action string (e.g. `"stop"`, `"warn"`,
/// `"compact"`) determined by the detection policy.
#[derive(Debug, Clone)]
pub struct ConvergenceDetectedContext {
    /// Configured action to take (e.g. `"stop"`, `"warn"`, `"compact"`).
    ///
    /// Determined by the convergence detection policy configuration.
    /// `"stop"` halts the loop, `"warn"` logs and continues,
    /// `"compact"` triggers context compaction.
    pub action: String,
}