Skip to main content

codetether_agent/session/
event_rlm.rs

1//! RLM progress and completion payloads for [`SessionEvent`].
2//!
3//! The [`RlmProgressEvent`] type is the **bus-facing firewall** around the
4//! internal `ProcessProgress` struct in [`crate::rlm::router`]: only the
5//! fields that are safe to broadcast and persist are copied across. If
6//! `ProcessProgress` ever grows a sensitive field (auth token, absolute
7//! local path, raw prompt), the durable sink will not leak it.
8//!
9//! [`SessionEvent`]: crate::session::SessionEvent
10
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14/// One progress tick emitted by an in-flight RLM analysis loop.
15///
16/// Emitted at iteration boundaries so the TUI can render a spinner with
17/// `iteration / max_iterations` and a short status string. This type is
18/// constructed inside the session crate from the private `ProcessProgress`
19/// — do **not** add fields that should not appear on the bus or in
20/// persistent traces.
21///
22/// # Examples
23///
24/// ```rust
25/// use codetether_agent::session::RlmProgressEvent;
26/// use uuid::Uuid;
27///
28/// let p = RlmProgressEvent {
29///     trace_id: Uuid::nil(),
30///     iteration: 3,
31///     max_iterations: 15,
32///     status: "grepping".into(),
33/// };
34/// assert!(p.fraction() > 0.0);
35/// assert!(p.fraction() < 1.0);
36/// ```
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct RlmProgressEvent {
39    /// Correlates every event from a single RLM invocation, including the
40    /// terminal [`RlmCompletion`].
41    pub trace_id: Uuid,
42    /// 1-based iteration number currently executing.
43    pub iteration: usize,
44    /// Maximum iterations permitted by config.
45    pub max_iterations: usize,
46    /// Short human-readable status ("grepping", "summarising", etc.).
47    pub status: String,
48}
49
50impl RlmProgressEvent {
51    /// Fraction of the iteration budget consumed (0.0 – 1.0).
52    ///
53    /// Returns `0.0` when `max_iterations == 0`.
54    ///
55    /// # Examples
56    ///
57    /// ```rust
58    /// use codetether_agent::session::RlmProgressEvent;
59    /// use uuid::Uuid;
60    ///
61    /// let p = RlmProgressEvent {
62    ///     trace_id: Uuid::nil(),
63    ///     iteration: 5,
64    ///     max_iterations: 10,
65    ///     status: "x".into(),
66    /// };
67    /// assert!((p.fraction() - 0.5).abs() < 1e-9);
68    /// ```
69    pub fn fraction(&self) -> f64 {
70        if self.max_iterations == 0 {
71            0.0
72        } else {
73            (self.iteration as f64 / self.max_iterations as f64).clamp(0.0, 1.0)
74        }
75    }
76}
77
78/// Why a Recursive Language Model loop finished.
79///
80/// # Examples
81///
82/// ```rust
83/// use codetether_agent::session::RlmOutcome;
84///
85/// assert!(RlmOutcome::Converged.is_success());
86/// assert!(!RlmOutcome::Exhausted.is_success());
87/// assert!(!RlmOutcome::Aborted.is_success());
88/// ```
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum RlmOutcome {
92    /// The model produced a final answer within the iteration budget.
93    Converged,
94    /// The loop hit `max_iterations` without converging. Best-effort
95    /// partial summary is still returned.
96    Exhausted,
97    /// A sub-LLM or tool call failed irrecoverably.
98    Failed,
99    /// The caller signalled cancellation via the abort watch.
100    Aborted,
101}
102
103impl RlmOutcome {
104    /// Returns `true` only for [`Self::Converged`].
105    ///
106    /// # Examples
107    ///
108    /// ```rust
109    /// use codetether_agent::session::RlmOutcome;
110    ///
111    /// assert!(RlmOutcome::Converged.is_success());
112    /// assert!(!RlmOutcome::Failed.is_success());
113    /// ```
114    pub const fn is_success(self) -> bool {
115        matches!(self, Self::Converged)
116    }
117}
118
119/// Terminal record for a single RLM invocation.
120///
121/// Persisted durably so cost post-mortems and trace-driven tuning jobs
122/// can reconstruct how each compaction or big-tool-output run behaved.
123///
124/// # Examples
125///
126/// ```rust
127/// use codetether_agent::session::{RlmCompletion, RlmOutcome};
128/// use uuid::Uuid;
129///
130/// let c = RlmCompletion {
131///     trace_id: Uuid::nil(),
132///     outcome: RlmOutcome::Converged,
133///     iterations: 4,
134///     subcalls: 1,
135///     input_tokens: 42_000,
136///     output_tokens: 2_100,
137///     elapsed_ms: 3_412,
138///     reason: None,
139///     root_model: "zai/glm-5".into(),
140///     subcall_model_used: None,
141/// };
142/// assert!(c.compression_ratio() > 0.0);
143/// assert!(c.compression_ratio() < 1.0);
144/// ```
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct RlmCompletion {
147    /// Correlates this completion with its progress events.
148    pub trace_id: Uuid,
149    /// Why the loop finished.
150    pub outcome: RlmOutcome,
151    /// Iterations actually executed.
152    pub iterations: usize,
153    /// Sub-LLM calls issued during the loop.
154    pub subcalls: usize,
155    /// Input tokens (the raw blob handed to the router).
156    pub input_tokens: usize,
157    /// Output tokens (the produced summary / final answer).
158    pub output_tokens: usize,
159    /// Total wall-clock duration in milliseconds.
160    pub elapsed_ms: u64,
161    /// Human-readable explanation for non-[`RlmOutcome::Converged`] outcomes.
162    pub reason: Option<String>,
163    /// Model used for iteration 1 (the root model).
164    pub root_model: String,
165    /// Model used for iterations ≥ 2, if
166    /// [`RlmConfig::subcall_model`](crate::rlm::RlmConfig::subcall_model)
167    /// was configured and resolved. `None` means all iterations used
168    /// [`Self::root_model`].
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub subcall_model_used: Option<String>,
171}
172
173impl RlmCompletion {
174    /// `output_tokens / input_tokens`, clamped so zero-input loops return
175    /// `0.0` rather than `NaN`.
176    ///
177    /// Values below `1.0` indicate compression; above `1.0` indicate
178    /// expansion (rare, but possible when the model rewrites a terse blob
179    /// into a verbose explanation).
180    ///
181    /// # Examples
182    ///
183    /// ```rust
184    /// use codetether_agent::session::{RlmCompletion, RlmOutcome};
185    /// use uuid::Uuid;
186    ///
187    /// let c = RlmCompletion {
188    ///     trace_id: Uuid::nil(),
189    ///     outcome: RlmOutcome::Converged,
190    ///     iterations: 1, subcalls: 0,
191    ///     input_tokens: 1000, output_tokens: 100,
192    ///     elapsed_ms: 0, reason: None,
193    ///     root_model: "m".into(), subcall_model_used: None,
194    /// };
195    /// assert!((c.compression_ratio() - 0.1).abs() < 1e-9);
196    /// ```
197    pub fn compression_ratio(&self) -> f64 {
198        if self.input_tokens == 0 {
199            0.0
200        } else {
201            self.output_tokens as f64 / self.input_tokens as f64
202        }
203    }
204}
205
206/// Emitted when a configured `subcall_model` could not be resolved,
207/// forcing the router to fall back to the root model.
208///
209/// Durable — a misconfigured subcall model is a cost problem (root models
210/// are typically more expensive) that warrants alerting.
211///
212/// # Examples
213///
214/// ```rust
215/// use codetether_agent::session::RlmSubcallFallback;
216/// use uuid::Uuid;
217///
218/// let fb = RlmSubcallFallback {
219///     trace_id: Uuid::nil(),
220///     configured: "minimax-fast".into(),
221///     using: "zai/glm-5".into(),
222///     reason: "provider not found in registry".into(),
223/// };
224/// assert_ne!(fb.configured, fb.using);
225/// ```
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct RlmSubcallFallback {
228    /// Correlates with the enclosing RLM run.
229    pub trace_id: Uuid,
230    /// The `subcall_model` string from config that failed to resolve.
231    pub configured: String,
232    /// The root model actually used instead.
233    pub using: String,
234    /// Why resolution failed (e.g. "provider not found", "rate limited").
235    pub reason: String,
236}