Skip to main content

laser_sdk/agent/
budget.rs

1use crate::agent::{Session, Sessions};
2use crate::context::LastN;
3use crate::error::LaserError;
4use crate::provenance::AgentTopic;
5use crate::types::ConversationId;
6use laser_wire::agent::{
7    AgentEnvelope, AgentErrorBody, AgentErrorCode, AgentKind, Budget, OPERATION_SESSION,
8    SessionEnd, SessionStart, SessionStatus, TaskState,
9};
10use laser_wire::framing::decode_named;
11use laser_wire::query::Value;
12use laser_wire::session::{SessionError, SessionInfo};
13use std::collections::{BTreeMap, HashMap};
14use std::sync::Mutex;
15use tracing::{debug, warn};
16
17/// The `SessionEnd.reason` of a session ended failed for passing its budget.
18pub(crate) const BUDGET_END_REASON: &str = "budget";
19
20impl Session {
21    /// Whether this session's usage has passed its budget: the summed input
22    /// and output tokens of its records over the token ceiling, or their
23    /// summed cost over the cost ceiling. A deployment that indexes sessions
24    /// answers from its index. On open Apache Iggy, or for a session the
25    /// index does not know yet, the retained session lane is folded: the
26    /// budget of the first start record against the `usage` of every record.
27    /// A session without a budget is never over it. The answer is eventually
28    /// consistent, so a budget is a cooperative limit, not a hard spending
29    /// cap.
30    pub async fn over_budget(&self) -> Result<bool, LaserError> {
31        Ok(self.budget_breach().await?.is_some())
32    }
33
34    // How this session passed its budget, `None` while it is within it.
35    pub(crate) async fn budget_breach(&self) -> Result<Option<BudgetBreach>, LaserError> {
36        if self.laser.capabilities().await.sessions {
37            match self.status().await {
38                Ok(info) => return Ok(BudgetBreach::of_info(&info)),
39                Err(LaserError::Session(
40                    SessionError::NotFound(_) | SessionError::NotRegistered(_),
41                )) => {}
42                Err(error) => return Err(error),
43            }
44        }
45        self.lane_budget_breach().await
46    }
47
48    // End the session failed with reason `budget`. The terminal latch makes
49    // a repeated call on this handle or its clones write the same record.
50    pub(crate) async fn fail_over_budget(&self, breach: &BudgetBreach) -> Result<(), LaserError> {
51        self.terminate(
52            TaskState::Failed,
53            SessionEnd {
54                reason: Some(BUDGET_END_REASON.to_owned()),
55                error: Some(breach.error_body()),
56            },
57        )
58        .await
59    }
60
61    async fn lane_budget_breach(&self) -> Result<Option<BudgetBreach>, LaserError> {
62        let records = self
63            .scope()
64            .fetch_with(vec![AgentTopic::Sessions], Box::new(LastN(usize::MAX)))
65            .await?;
66        let mut usage = LaneUsage::default();
67        for envelope in records.iter().filter_map(|record| record.envelope.as_ref()) {
68            usage.add(envelope);
69        }
70        Ok(BudgetBreach::of(
71            usage.budget,
72            usage.tokens,
73            usage.cost_micros,
74        ))
75    }
76}
77
78/// Which ceiling a session passed.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub(crate) enum BudgetDimension {
81    Tokens,
82    CostMicros,
83}
84
85/// How a session passed its budget.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub(crate) struct BudgetBreach {
88    pub(crate) dimension: Option<BudgetDimension>,
89    pub(crate) ceiling: u64,
90    pub(crate) spent: u64,
91}
92
93impl BudgetBreach {
94    /// A breach known only by its numbers, such as a workflow's own ceiling.
95    pub(crate) fn exceeded(ceiling: u64, spent: u64) -> Self {
96        Self {
97            dimension: None,
98            ceiling,
99            spent,
100        }
101    }
102
103    /// The error a caller stopped by this breach returns.
104    pub(crate) fn into_error(self) -> LaserError {
105        LaserError::BudgetExceeded {
106            ceiling: self.ceiling,
107            spent: self.spent,
108        }
109    }
110
111    // The token ceiling is checked first, then the cost ceiling, the rule
112    // the session index applies.
113    fn of(budget: Option<Budget>, tokens: u64, cost_micros: u64) -> Option<Self> {
114        let budget = budget?;
115        if let Some(ceiling) = budget.tokens
116            && tokens > ceiling
117        {
118            return Some(Self {
119                dimension: Some(BudgetDimension::Tokens),
120                ceiling,
121                spent: tokens,
122            });
123        }
124        if let Some(ceiling) = budget.cost_micros
125            && cost_micros > ceiling
126        {
127            return Some(Self {
128                dimension: Some(BudgetDimension::CostMicros),
129                ceiling,
130                spent: cost_micros,
131            });
132        }
133        None
134    }
135
136    // The index's flag decides. Its counters name the ceiling that was
137    // passed.
138    fn of_info(info: &SessionInfo) -> Option<Self> {
139        if !info.over_budget {
140            return None;
141        }
142        let tokens = info.tokens_in.saturating_add(info.tokens_out);
143        Some(
144            Self::of(info.budget, tokens, info.cost_micros)
145                .unwrap_or_else(|| Self::exceeded(0, tokens)),
146        )
147    }
148
149    fn error_body(&self) -> AgentErrorBody {
150        let message = match self.dimension {
151            Some(BudgetDimension::Tokens) => format!(
152                "the session went over its token budget: {} of {} tokens",
153                self.spent, self.ceiling
154            ),
155            Some(BudgetDimension::CostMicros) => format!(
156                "the session went over its cost budget: {} of {} micro-units",
157                self.spent, self.ceiling
158            ),
159            None => format!(
160                "the session went over its budget: spent {} of ceiling {}",
161                self.spent, self.ceiling
162            ),
163        };
164        let mut detail = BTreeMap::from([
165            ("ceiling".to_owned(), Value::Uint(self.ceiling)),
166            ("spent".to_owned(), Value::Uint(self.spent)),
167        ]);
168        if let Some(dimension) = self.dimension {
169            let name = match dimension {
170                BudgetDimension::Tokens => "tokens",
171                BudgetDimension::CostMicros => "cost_micros",
172            };
173            detail.insert("budget".to_owned(), Value::Str(name.to_owned()));
174        }
175        AgentErrorBody {
176            code: AgentErrorCode::Internal,
177            message: Some(message),
178            retryable: false,
179            detail: Some(detail),
180        }
181    }
182}
183
184/// The runtime's budget check before a work record reaches the handler. It
185/// reads the session index only, one read per session per poll batch, so a
186/// deployment without the index never enforces in the runtime.
187#[derive(Default)]
188pub(crate) struct BudgetGate {
189    // Per partition, the head offset of the poll the verdicts came from and
190    // each session's verdict. Records of one poll share their partition's
191    // head offset, so a later poll that moved the head reads again.
192    batches: Mutex<HashMap<u32, Batch>>,
193    // The handles of sessions this runtime fails for their budget, kept
194    // until the index shows them ended, so a retried failure repeats the
195    // same terminal record and a confirmed one is not written again.
196    ending: Mutex<HashMap<ConversationId, Ending>>,
197}
198
199impl BudgetGate {
200    /// Whether a work record of `session`, delivered from `partition` by a
201    /// poll that saw `head`, may reach the handler. A session over its
202    /// budget is ended failed with reason `budget` through `lens`, once, and
203    /// its record is not handled. A failed index read or terminal write is
204    /// logged: the read lets the record through, the write is retried by
205    /// the session's next record.
206    pub(crate) async fn admit(
207        &self,
208        sessions: &Sessions,
209        session: ConversationId,
210        partition: u32,
211        head: u64,
212        lens: impl FnOnce() -> Session,
213    ) -> bool {
214        if !sessions.laser().capabilities().await.sessions {
215            return true;
216        }
217        let verdict = match self.cached(partition, head, session) {
218            Some(verdict) => verdict,
219            None => {
220                let verdict = match sessions.get(session).await {
221                    Ok(info) => Verdict {
222                        breach: BudgetBreach::of_info(&info),
223                        ended: matches!(
224                            info.status,
225                            SessionStatus::Completed
226                                | SessionStatus::Failed
227                                | SessionStatus::Canceled
228                        ),
229                    },
230                    Err(LaserError::Session(
231                        SessionError::NotFound(_) | SessionError::NotRegistered(_),
232                    )) => Verdict {
233                        breach: None,
234                        ended: false,
235                    },
236                    Err(error) => {
237                        warn!(%error, %session, "reading the session budget failed, handling the record");
238                        return true;
239                    }
240                };
241                self.store(partition, head, session, verdict);
242                verdict
243            }
244        };
245        let Some(breach) = verdict.breach else {
246            return true;
247        };
248        if verdict.ended {
249            lock(&self.ending).remove(&session);
250            debug!(%session, "skipping work for an ended session over its budget");
251            return false;
252        }
253        let pending = {
254            let mut ending = lock(&self.ending);
255            match ending.get(&session) {
256                Some(entry) if entry.written => None,
257                Some(entry) => Some(entry.lens.clone()),
258                None => {
259                    let lens = lens();
260                    ending.insert(
261                        session,
262                        Ending {
263                            lens: lens.clone(),
264                            written: false,
265                        },
266                    );
267                    Some(lens)
268                }
269            }
270        };
271        if let Some(lens) = pending {
272            match lens.fail_over_budget(&breach).await {
273                Ok(()) => {
274                    if let Some(entry) = lock(&self.ending).get_mut(&session) {
275                        entry.written = true;
276                    }
277                    debug!(%session, "ended a session over its budget");
278                }
279                Err(error) => {
280                    warn!(%error, %session, "failed to end a session over its budget, its next record retries");
281                }
282            }
283        }
284        false
285    }
286
287    fn cached(&self, partition: u32, head: u64, session: ConversationId) -> Option<Verdict> {
288        let batches = lock(&self.batches);
289        let batch = batches.get(&partition)?;
290        if batch.head != head {
291            return None;
292        }
293        batch.verdicts.get(&session).copied()
294    }
295
296    fn store(&self, partition: u32, head: u64, session: ConversationId, verdict: Verdict) {
297        let mut batches = lock(&self.batches);
298        let batch = batches.entry(partition).or_insert_with(|| Batch {
299            head,
300            verdicts: HashMap::new(),
301        });
302        if batch.head != head {
303            *batch = Batch {
304                head,
305                verdicts: HashMap::new(),
306            };
307        }
308        batch.verdicts.insert(session, verdict);
309    }
310}
311
312struct Batch {
313    head: u64,
314    verdicts: HashMap<ConversationId, Verdict>,
315}
316
317#[derive(Clone, Copy)]
318struct Verdict {
319    breach: Option<BudgetBreach>,
320    ended: bool,
321}
322
323struct Ending {
324    lens: Session,
325    written: bool,
326}
327
328// The budget of the first start record on the lane and the usage summed
329// over every record, as the session index folds them.
330#[derive(Default)]
331struct LaneUsage {
332    started: bool,
333    budget: Option<Budget>,
334    tokens: u64,
335    cost_micros: u64,
336}
337
338impl LaneUsage {
339    fn add(&mut self, envelope: &AgentEnvelope) {
340        if let Some(usage) = envelope.usage {
341            self.tokens = self
342                .tokens
343                .saturating_add(usage.input_tokens)
344                .saturating_add(usage.output_tokens);
345            self.cost_micros = self
346                .cost_micros
347                .saturating_add(usage.cost_micros.unwrap_or(0));
348        }
349        if !self.started
350            && envelope.kind == AgentKind::Status
351            && envelope.operation.as_deref() == Some(OPERATION_SESSION)
352            && let Ok(start) = decode_named::<SessionStart>(&envelope.body)
353        {
354            self.started = true;
355            self.budget = start.budget;
356        }
357    }
358}
359
360fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
361    mutex
362        .lock()
363        .unwrap_or_else(std::sync::PoisonError::into_inner)
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    fn budget(tokens: Option<u64>, cost_micros: Option<u64>) -> Option<Budget> {
371        Some(Budget {
372            tokens,
373            cost_micros,
374        })
375    }
376
377    #[test]
378    fn given_no_budget_when_usage_grows_then_should_never_breach() {
379        assert_eq!(BudgetBreach::of(None, u64::MAX, u64::MAX), None);
380        assert_eq!(BudgetBreach::of(budget(None, None), 10, 10), None);
381    }
382
383    #[test]
384    fn given_a_token_ceiling_when_usage_reaches_it_then_should_breach_only_past_it() {
385        assert_eq!(BudgetBreach::of(budget(Some(100), None), 100, 0), None);
386        assert_eq!(
387            BudgetBreach::of(budget(Some(100), None), 101, 0),
388            Some(BudgetBreach {
389                dimension: Some(BudgetDimension::Tokens),
390                ceiling: 100,
391                spent: 101,
392            })
393        );
394    }
395
396    #[test]
397    fn given_a_cost_ceiling_when_cost_passes_it_then_should_name_the_cost() {
398        assert_eq!(
399            BudgetBreach::of(budget(Some(1_000), Some(50)), 10, 51),
400            Some(BudgetBreach {
401                dimension: Some(BudgetDimension::CostMicros),
402                ceiling: 50,
403                spent: 51,
404            })
405        );
406    }
407
408    #[test]
409    fn given_a_breach_when_described_then_should_name_the_budget() {
410        let body = BudgetBreach::of(budget(Some(5), None), 9, 0)
411            .expect("over the token ceiling")
412            .error_body();
413        assert!(
414            body.message
415                .as_deref()
416                .is_some_and(|message| message.contains("token budget"))
417        );
418        let detail = body.detail.expect("the breach carries its numbers");
419        assert_eq!(detail.get("budget"), Some(&Value::Str("tokens".to_owned())));
420        assert_eq!(detail.get("ceiling"), Some(&Value::Uint(5)));
421        assert_eq!(detail.get("spent"), Some(&Value::Uint(9)));
422        assert!(!body.retryable);
423    }
424}