1use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use futures::{Stream, StreamExt};
22use tokio_util::sync::CancellationToken;
23
24use crate::runtime::budget::{TurnBudget, TOOL_CALL_NOT_EXECUTED};
25use crate::runtime::{CancelCause, TurnCompletion};
26use crate::tools::WATCHER_EXIT_ACK;
27use crate::{
28 LlmEvent, Runtime, SessionEvent, SessionLimits, SharedMessage, StreamEvent, TurnError,
29};
30
31#[derive(Debug, Clone, Copy, Default, PartialEq)]
33pub struct SessionTotals {
34 pub tokens: u64,
36 pub cost_usd: f64,
38 pub tool_calls: u64,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum SessionLimit {
45 Tokens,
46 Cost,
47 DailyCost,
48 Time,
49 ToolCalls,
50}
51
52impl SessionLimit {
53 pub fn exit_reason(self) -> &'static str {
55 match self {
56 SessionLimit::Tokens => "token_limit",
57 SessionLimit::Cost => "cost_limit",
58 SessionLimit::DailyCost => "daily_cost_limit",
59 SessionLimit::Time => "time_limit",
60 SessionLimit::ToolCalls => "tool_limit",
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy)]
67pub struct SessionAllowance<'a> {
68 pub limits: &'a SessionLimits,
69 pub daily_spent_before: f64,
71}
72
73impl SessionAllowance<'_> {
74 fn max_duration(&self) -> Duration {
75 Duration::from_secs(self.limits.max_session_duration_mins.saturating_mul(60))
76 }
77
78 pub fn turn_deadline(&self, session_start: Instant) -> Instant {
81 session_start + self.max_duration() + TURN_DEADLINE_GRACE
82 }
83
84 pub fn limit_reached(&self, totals: &SessionTotals, elapsed: Duration) -> Option<SessionLimit> {
86 if totals.tokens >= self.limits.max_session_tokens {
87 Some(SessionLimit::Tokens)
88 } else if totals.cost_usd >= self.limits.max_session_cost_usd {
89 Some(SessionLimit::Cost)
90 } else if self.daily_spent_before + totals.cost_usd >= self.limits.max_daily_cost_usd {
91 Some(SessionLimit::DailyCost)
92 } else if elapsed >= self.max_duration() {
93 Some(SessionLimit::Time)
94 } else if totals.tool_calls >= self.limits.max_tool_calls {
95 Some(SessionLimit::ToolCalls)
96 } else {
97 None
98 }
99 }
100
101 pub fn turn_budget(
105 &self,
106 role: &TurnBudget,
107 totals: &SessionTotals,
108 elapsed: Duration,
109 ) -> TurnBudget {
110 let limits = self.limits;
111 let session_cost_left = limits.max_session_cost_usd - totals.cost_usd;
112 let daily_cost_left =
113 limits.max_daily_cost_usd - (self.daily_spent_before + totals.cost_usd);
114 let remaining = TurnBudget {
115 max_provider_rounds: None,
116 max_tool_calls: Some(
117 u32::try_from(limits.max_tool_calls.saturating_sub(totals.tool_calls))
118 .unwrap_or(u32::MAX),
119 ),
120 max_elapsed: Some(self.max_duration().saturating_sub(elapsed)),
121 max_accumulated_tool_result_bytes: None,
122 max_context_tokens: None,
123 max_cost_usd: Some(session_cost_left.min(daily_cost_left).max(0.0)),
124 max_total_tokens: Some(limits.max_session_tokens.saturating_sub(totals.tokens)),
125 max_round_renewals: u32::MAX,
126 };
127 role.tighter(&remaining)
128 }
129}
130
131pub fn usage_cost(
134 model: &str,
135 input: u64,
136 output: u64,
137 cache_read: u64,
138 cache_write: u64,
139 cache_write_5m: Option<u64>,
140 cache_write_1h: Option<u64>,
141) -> f64 {
142 crate::pricing::calculate_cost_optional_split(
143 model,
144 input,
145 output,
146 cache_read,
147 cache_write,
148 cache_write_5m,
149 cache_write_1h,
150 )
151}
152
153#[derive(Debug, Default)]
155pub struct TurnReport {
156 pub history: Option<Vec<SharedMessage>>,
158 pub handoff_saved: bool,
160 pub error: Option<TurnError>,
162}
163
164pub async fn drive_turn<S>(
168 mut stream: S,
169 model: &str,
170 totals: &mut SessionTotals,
171 mut on_event: impl FnMut(&StreamEvent, &SessionTotals),
172) -> TurnReport
173where
174 S: Stream<Item = StreamEvent> + Unpin,
175{
176 let mut report = TurnReport::default();
177 let mut exit_call: Option<String> = None;
178 while let Some(event) = stream.next().await {
179 match &event {
180 StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id })
181 if tool_name == "watcher_exit" =>
182 {
183 exit_call = Some(tool_id.clone());
184 }
185 StreamEvent::Llm(LlmEvent::ToolResult { tool_id, result }) => {
186 if !result.starts_with(TOOL_CALL_NOT_EXECUTED) && !result.starts_with("Canceled") {
189 totals.tool_calls = totals.tool_calls.saturating_add(1);
190 }
191 if exit_call.as_deref() == Some(tool_id.as_str())
192 && result.starts_with(WATCHER_EXIT_ACK)
193 {
194 report.handoff_saved = true;
195 }
196 }
197 StreamEvent::Session(SessionEvent::Usage {
198 input_tokens,
199 output_tokens,
200 cache_read_input_tokens,
201 cache_creation_input_tokens,
202 cache_creation_5m,
203 cache_creation_1h,
204 model: event_model,
205 }) => {
206 totals.tokens = totals
207 .tokens
208 .saturating_add(input_tokens.saturating_add(*output_tokens));
209 totals.cost_usd += usage_cost(
210 event_model.as_deref().unwrap_or(model),
211 *input_tokens,
212 *output_tokens,
213 *cache_read_input_tokens,
214 *cache_creation_input_tokens,
215 *cache_creation_5m,
216 *cache_creation_1h,
217 );
218 }
219 StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
220 report.history = Some(history.clone());
221 }
222 StreamEvent::Session(SessionEvent::Error(error)) => {
223 report.error = Some(error.clone());
224 }
225 _ => {}
226 }
227 on_event(&event, totals);
228 }
229 report
230}
231
232pub fn push_user_prompt(messages: &mut Vec<SharedMessage>, prompt: &str) {
236 if let Some(last) = messages.last() {
237 if last["role"] == "user" {
238 let mut merged = (**last).clone();
239 let text = serde_json::json!({"type": "text", "text": prompt});
240 match merged["content"].take() {
241 serde_json::Value::Array(mut blocks) => {
242 blocks.push(text);
243 merged["content"] = serde_json::Value::Array(blocks);
244 }
245 serde_json::Value::String(s) => {
246 merged["content"] = serde_json::json!([{"type": "text", "text": s}, text]);
247 }
248 _ => merged["content"] = serde_json::json!([text]),
249 }
250 *messages.last_mut().expect("non-empty") = std::sync::Arc::new(merged);
251 return;
252 }
253 }
254 messages.push(std::sync::Arc::new(
255 serde_json::json!({"role": "user", "content": prompt}),
256 ));
257}
258
259pub const HANDOFF_PROMPT: &str = "You're being shut down (resource limit reached). Call watcher_exit NOW with your handoff state — summarize what you did, what's pending, and any context your next session needs.";
261
262pub const HANDOFF_TIMEOUT: Duration = Duration::from_secs(60);
264
265pub async fn watch_turn(
270 cancel: CancellationToken,
271 completion: TurnCompletion,
272 interrupted: Arc<AtomicBool>,
273 turn_over: Arc<AtomicBool>,
274 deadline: Option<Instant>,
275 poll: Duration,
276) {
277 while !turn_over.load(Ordering::Acquire) && !cancel.is_cancelled() {
278 if interrupted.load(Ordering::Acquire) || deadline.is_some_and(|d| Instant::now() >= d) {
279 completion.note_cancel_cause(CancelCause::Host);
280 cancel.cancel();
281 break;
282 }
283 tokio::time::sleep(poll).await;
284 }
285}
286
287pub async fn run_turn(
291 runtime: &mut Runtime,
292 messages: &mut Vec<SharedMessage>,
293 budget: TurnBudget,
294 totals: &mut SessionTotals,
295 interrupted: &Arc<AtomicBool>,
296 deadline: Option<Instant>,
297 on_event: impl FnMut(&StreamEvent, &SessionTotals),
298) -> TurnReport {
299 runtime.set_turn_budget(budget);
300 let cancel = CancellationToken::new();
301 let completion = TurnCompletion::new();
302 let turn_over = Arc::new(AtomicBool::new(false));
303 tokio::spawn(watch_turn(
304 cancel.clone(),
305 completion.clone(),
306 Arc::clone(interrupted),
307 Arc::clone(&turn_over),
308 deadline,
309 Duration::from_millis(250),
310 ));
311 let model = runtime.model().to_string();
312 let stream = runtime
313 .run_stream_tracked(messages.clone(), cancel, None, None, false, completion)
314 .await;
315 let report = drive_turn(stream, &model, totals, on_event).await;
316 turn_over.store(true, Ordering::Release);
317 if let Some(history) = &report.history {
318 *messages = history.clone();
319 }
320 report
321}
322
323pub const TURN_DEADLINE_GRACE: Duration = Duration::from_secs(5);
328
329#[allow(clippy::too_many_arguments)]
332pub async fn run_session_turn(
333 runtime: &mut Runtime,
334 messages: &mut Vec<SharedMessage>,
335 allowance: &SessionAllowance<'_>,
336 role: &TurnBudget,
337 totals: &mut SessionTotals,
338 session_start: Instant,
339 interrupted: &Arc<AtomicBool>,
340 on_event: impl FnMut(&StreamEvent, &SessionTotals),
341) -> TurnReport {
342 let budget = allowance.turn_budget(role, totals, session_start.elapsed());
343 let deadline = allowance.turn_deadline(session_start);
344 run_turn(
345 runtime,
346 messages,
347 budget,
348 totals,
349 interrupted,
350 Some(deadline),
351 on_event,
352 )
353 .await
354}
355
356pub async fn run_handoff_turn(
360 runtime: &mut Runtime,
361 messages: &mut Vec<SharedMessage>,
362 role: &TurnBudget,
363 totals: &mut SessionTotals,
364 interrupted: &Arc<AtomicBool>,
365 on_event: impl FnMut(&StreamEvent, &SessionTotals),
366) -> TurnReport {
367 push_user_prompt(messages, HANDOFF_PROMPT);
368 let budget = role.tighter(&TurnBudget {
369 max_elapsed: Some(HANDOFF_TIMEOUT),
370 max_round_renewals: u32::MAX,
371 ..TurnBudget::for_role(crate::runtime::budget::TurnRole::Foreground)
372 });
373 run_turn(
374 runtime,
375 messages,
376 budget,
377 totals,
378 interrupted,
379 Some(Instant::now() + HANDOFF_TIMEOUT),
380 on_event,
381 )
382 .await
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::runtime::budget::{TurnRole, TOOL_CALL_BUDGET_REFUSAL};
389
390 fn limits() -> SessionLimits {
391 SessionLimits {
392 max_session_tokens: 1_000,
393 max_session_duration_mins: 60,
394 max_session_cost_usd: 0.50,
395 max_daily_cost_usd: 10.0,
396 max_tool_calls: 10,
397 ..SessionLimits::default()
398 }
399 }
400
401 #[test]
402 fn limits_are_reached_at_their_configured_values() {
403 let limits = limits();
404 let allowance = SessionAllowance {
405 limits: &limits,
406 daily_spent_before: 0.0,
407 };
408 let none = SessionTotals::default();
409 assert_eq!(allowance.limit_reached(&none, Duration::ZERO), None);
410 for (totals, elapsed, expected) in [
411 (
412 SessionTotals {
413 tokens: 1_000,
414 ..none
415 },
416 Duration::ZERO,
417 SessionLimit::Tokens,
418 ),
419 (
420 SessionTotals {
421 cost_usd: 0.50,
422 ..none
423 },
424 Duration::ZERO,
425 SessionLimit::Cost,
426 ),
427 (
428 SessionTotals {
429 tool_calls: 10,
430 ..none
431 },
432 Duration::ZERO,
433 SessionLimit::ToolCalls,
434 ),
435 (none, Duration::from_secs(3600), SessionLimit::Time),
436 ] {
437 assert_eq!(allowance.limit_reached(&totals, elapsed), Some(expected));
438 }
439 let late_in_the_day = SessionAllowance {
440 limits: &limits,
441 daily_spent_before: 9.80,
442 };
443 let spent = SessionTotals {
444 cost_usd: 0.20,
445 ..none
446 };
447 assert_eq!(
448 late_in_the_day.limit_reached(&spent, Duration::ZERO),
449 Some(SessionLimit::DailyCost)
450 );
451 }
452
453 #[test]
454 fn the_turn_budget_is_the_remaining_allowance_capped_by_the_role() {
455 let limits = limits();
456 let allowance = SessionAllowance {
457 limits: &limits,
458 daily_spent_before: 9.90,
459 };
460 let role = TurnBudget::for_role(TurnRole::Autonomous);
461 let totals = SessionTotals {
462 tokens: 400,
463 cost_usd: 0.05,
464 tool_calls: 8,
465 };
466 let budget = allowance.turn_budget(&role, &totals, Duration::from_secs(50 * 60));
467 assert_eq!(budget.max_tool_calls, Some(2));
468 assert_eq!(budget.max_total_tokens, Some(600));
469 assert_eq!(budget.max_elapsed, Some(Duration::from_secs(10 * 60)));
470 assert!((budget.max_cost_usd.unwrap() - 0.05).abs() < 1e-9);
472 assert_eq!(budget.max_provider_rounds, Some(24));
474 assert_eq!(budget.max_round_renewals, 0);
475
476 let fresh = allowance.turn_budget(&role, &SessionTotals::default(), Duration::ZERO);
477 assert_eq!(fresh.max_tool_calls, Some(10));
478 let roomy = SessionLimits {
479 max_tool_calls: 500,
480 ..limits
481 };
482 let roomy = SessionAllowance {
483 limits: &roomy,
484 daily_spent_before: 0.0,
485 };
486 assert_eq!(
487 roomy
488 .turn_budget(&role, &SessionTotals::default(), Duration::ZERO)
489 .max_tool_calls,
490 Some(96),
491 "the role's per-turn cap wins when the session has more left"
492 );
493 }
494
495 #[test]
496 fn cache_heavy_usage_is_priced_with_the_runtime_table() {
497 let model = "claude-opus-4-1";
498 let cost = usage_cost(model, 2_000, 1_000, 400_000, 100_000, None, None);
499 let uncached_only = usage_cost(model, 2_000, 1_000, 0, 0, None, None);
500 assert_eq!(
501 cost,
502 crate::pricing::calculate_cost_optional_split(
503 model, 2_000, 1_000, 400_000, 100_000, None, None
504 )
505 );
506 assert!(
507 cost > uncached_only * 5.0,
508 "cache reads/writes dominate a long watcher context: {cost} vs {uncached_only}"
509 );
510 }
511
512 #[tokio::test]
513 async fn drive_turn_meters_cache_tokens_and_counts_only_executed_calls() {
514 let events = vec![
515 StreamEvent::Llm(LlmEvent::ToolUseStart {
516 tool_name: "ls".into(),
517 tool_id: "t1".into(),
518 }),
519 StreamEvent::Llm(LlmEvent::ToolUseStart {
520 tool_name: "ls".into(),
521 tool_id: "t2".into(),
522 }),
523 StreamEvent::Session(SessionEvent::Usage {
524 input_tokens: 100,
525 output_tokens: 50,
526 cache_read_input_tokens: 200_000,
527 cache_creation_input_tokens: 20_000,
528 cache_creation_5m: None,
529 cache_creation_1h: None,
530 model: None,
531 }),
532 StreamEvent::Llm(LlmEvent::ToolResult {
533 tool_id: "t1".into(),
534 result: "ok".into(),
535 }),
536 StreamEvent::Llm(LlmEvent::ToolResult {
537 tool_id: "t2".into(),
538 result: TOOL_CALL_BUDGET_REFUSAL.into(),
539 }),
540 ];
541 let mut totals = SessionTotals::default();
542 let report = drive_turn(
543 futures::stream::iter(events),
544 "claude-sonnet-4-5",
545 &mut totals,
546 |_, _| {},
547 )
548 .await;
549 assert_eq!(totals.tokens, 150);
550 assert_eq!(totals.tool_calls, 1, "the refused call is not counted");
551 let expected = usage_cost("claude-sonnet-4-5", 100, 50, 200_000, 20_000, None, None);
552 assert!((totals.cost_usd - expected).abs() < 1e-12);
553 assert!(!report.handoff_saved);
554 }
555
556 #[tokio::test]
557 async fn only_a_successful_watcher_exit_result_counts_as_a_saved_handoff() {
558 let start = StreamEvent::Llm(LlmEvent::ToolUseStart {
559 tool_name: "watcher_exit".into(),
560 tool_id: "x1".into(),
561 });
562 for (result, saved) in [
563 (format!("{WATCHER_EXIT_ACK} Reason: done"), true),
564 (TOOL_CALL_BUDGET_REFUSAL.to_string(), false),
565 ("Canceled by user".to_string(), false),
566 ("Error: Failed to rename handoff file".to_string(), false),
567 ] {
568 let mut totals = SessionTotals::default();
569 let report = drive_turn(
570 futures::stream::iter(vec![
571 start.clone(),
572 StreamEvent::Llm(LlmEvent::ToolResult {
573 tool_id: "x1".into(),
574 result,
575 }),
576 ]),
577 "claude-sonnet-4-5",
578 &mut totals,
579 |_, _| {},
580 )
581 .await;
582 assert_eq!(report.handoff_saved, saved);
583 }
584 let mut totals = SessionTotals::default();
586 let report = drive_turn(
587 futures::stream::iter(vec![start]),
588 "claude-sonnet-4-5",
589 &mut totals,
590 |_, _| {},
591 )
592 .await;
593 assert!(!report.handoff_saved);
594 }
595
596 #[tokio::test]
597 async fn turn_watcher_cancels_on_signal_and_at_the_deadline_only() {
598 let poll = Duration::from_millis(5);
599 let cancel = CancellationToken::new();
601 let watcher = tokio::spawn(watch_turn(
602 cancel.clone(),
603 TurnCompletion::new(),
604 Arc::new(AtomicBool::new(true)),
605 Arc::new(AtomicBool::new(false)),
606 None,
607 poll,
608 ));
609 tokio::time::timeout(Duration::from_secs(5), cancel.cancelled())
610 .await
611 .expect("signal cancels the turn");
612 watcher.await.unwrap();
613 let cancel = CancellationToken::new();
615 let watcher = tokio::spawn(watch_turn(
616 cancel.clone(),
617 TurnCompletion::new(),
618 Arc::new(AtomicBool::new(false)),
619 Arc::new(AtomicBool::new(false)),
620 Some(Instant::now()),
621 poll,
622 ));
623 tokio::time::timeout(Duration::from_secs(5), cancel.cancelled())
624 .await
625 .expect("deadline cancels the turn");
626 watcher.await.unwrap();
627 let cancel = CancellationToken::new();
629 let turn_over = Arc::new(AtomicBool::new(false));
630 let watcher = tokio::spawn(watch_turn(
631 cancel.clone(),
632 TurnCompletion::new(),
633 Arc::new(AtomicBool::new(false)),
634 Arc::clone(&turn_over),
635 None,
636 poll,
637 ));
638 turn_over.store(true, Ordering::Release);
639 tokio::time::timeout(Duration::from_secs(5), watcher)
640 .await
641 .expect("watcher exits when the turn ends")
642 .unwrap();
643 assert!(!cancel.is_cancelled());
644 }
645
646 #[test]
647 fn a_prompt_after_tool_results_joins_the_last_user_message() {
648 let mut messages: Vec<SharedMessage> = vec![
649 std::sync::Arc::new(serde_json::json!({"role": "user", "content": "boot"})),
650 std::sync::Arc::new(serde_json::json!({"role": "assistant", "content": [
651 {"type": "tool_use", "id": "t1", "name": "ls", "input": {}}
652 ]})),
653 std::sync::Arc::new(serde_json::json!({"role": "user", "content": [
654 {"type": "tool_result", "tool_use_id": "t1", "content": "x"}
655 ]})),
656 ];
657 push_user_prompt(&mut messages, "write your handoff");
658 assert_eq!(messages.len(), 3);
659 let blocks = messages[2]["content"].as_array().unwrap();
660 assert_eq!(blocks[0]["type"], "tool_result");
661 assert_eq!(blocks[1]["text"], "write your handoff");
662
663 messages.push(std::sync::Arc::new(
664 serde_json::json!({"role": "assistant", "content": "ok"}),
665 ));
666 push_user_prompt(&mut messages, "next");
667 assert_eq!(messages.len(), 5);
668 assert_eq!(messages[4]["content"], "next");
669 }
670}