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
17pub(crate) const BUDGET_END_REASON: &str = "budget";
19
20impl Session {
21 pub async fn over_budget(&self) -> Result<bool, LaserError> {
31 Ok(self.budget_breach().await?.is_some())
32 }
33
34 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub(crate) enum BudgetDimension {
81 Tokens,
82 CostMicros,
83}
84
85#[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 pub(crate) fn exceeded(ceiling: u64, spent: u64) -> Self {
96 Self {
97 dimension: None,
98 ceiling,
99 spent,
100 }
101 }
102
103 pub(crate) fn into_error(self) -> LaserError {
105 LaserError::BudgetExceeded {
106 ceiling: self.ceiling,
107 spent: self.spent,
108 }
109 }
110
111 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 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#[derive(Default)]
188pub(crate) struct BudgetGate {
189 batches: Mutex<HashMap<u32, Batch>>,
193 ending: Mutex<HashMap<ConversationId, Ending>>,
197}
198
199impl BudgetGate {
200 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#[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}