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}