everruns_core/capabilities/
usage_limit_auto_continue.rs1use super::{Capability, CapabilityLocalization, CapabilityStatus, RiskLevel};
24use crate::capability_types::AgentCapabilityConfig;
25use crate::llm_error_hook::{LlmErrorContext, LlmErrorHook, LlmErrorHookOutcome};
26use async_trait::async_trait;
27use serde_json::{Value, json};
28use std::sync::Arc;
29
30pub const USAGE_LIMIT_AUTO_CONTINUE_CAPABILITY_ID: &str = "usage_limit_auto_continue";
31
32pub const DEFAULT_CONTINUATION_DELAY_SECS: i64 = 120;
36
37pub const DEFAULT_CONTINUATION_PROMPT: &str = "Continue tasks";
39
40const MAX_CONTINUATION_DELAY_SECS: i64 = 86_400;
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct AutoContinueConfig {
47 pub delay_seconds: i64,
49 pub prompt: String,
51}
52
53impl Default for AutoContinueConfig {
54 fn default() -> Self {
55 Self {
56 delay_seconds: DEFAULT_CONTINUATION_DELAY_SECS,
57 prompt: DEFAULT_CONTINUATION_PROMPT.to_string(),
58 }
59 }
60}
61
62impl AutoContinueConfig {
63 pub fn from_config_value(config: &Value) -> Self {
66 let delay_seconds = config
67 .get("delay_seconds")
68 .and_then(Value::as_i64)
69 .filter(|secs| (0..=MAX_CONTINUATION_DELAY_SECS).contains(secs))
70 .unwrap_or(DEFAULT_CONTINUATION_DELAY_SECS);
71
72 let prompt = config
73 .get("prompt")
74 .and_then(Value::as_str)
75 .map(str::trim)
76 .filter(|p| !p.is_empty())
77 .unwrap_or(DEFAULT_CONTINUATION_PROMPT)
78 .to_string();
79
80 Self {
81 delay_seconds,
82 prompt,
83 }
84 }
85}
86
87pub fn resolve_usage_limit_auto_continue(
90 configs: &[AgentCapabilityConfig],
91) -> Option<AutoContinueConfig> {
92 configs
93 .iter()
94 .find(|config| config.capability_id() == USAGE_LIMIT_AUTO_CONTINUE_CAPABILITY_ID)
95 .map(|config| AutoContinueConfig::from_config_value(&config.config))
96}
97
98pub struct UsageLimitAutoContinueCapability;
99
100impl Capability for UsageLimitAutoContinueCapability {
101 fn id(&self) -> &str {
102 USAGE_LIMIT_AUTO_CONTINUE_CAPABILITY_ID
103 }
104
105 fn name(&self) -> &str {
106 "Auto-Continue After Usage Limit"
107 }
108
109 fn description(&self) -> &str {
110 "When an LLM usage limit is reached, automatically resume the interrupted work shortly after the limit resets."
111 }
112
113 fn localizations(&self) -> Vec<CapabilityLocalization> {
114 vec![CapabilityLocalization::text(
115 "uk",
116 "Автопродовження після ліміту використання",
117 "Коли досягнуто ліміт використання LLM, автоматично відновлює перервану роботу невдовзі після скидання ліміту.",
118 )]
119 }
120
121 fn status(&self) -> CapabilityStatus {
122 CapabilityStatus::Available
123 }
124
125 fn risk_level(&self) -> RiskLevel {
126 RiskLevel::Low
127 }
128
129 fn icon(&self) -> Option<&str> {
130 Some("clock")
131 }
132
133 fn category(&self) -> Option<&str> {
134 Some("Core")
135 }
136
137 fn llm_error_hook(&self) -> Option<Arc<dyn LlmErrorHook>> {
138 Some(Arc::new(UsageLimitAutoContinueHook))
139 }
140
141 fn config_schema(&self) -> Option<Value> {
142 Some(json!({
143 "type": "object",
144 "properties": {
145 "delay_seconds": {
146 "type": "integer",
147 "title": "Continuation delay (seconds)",
148 "description": "How long to wait after the reported reset time before resuming work. A small buffer avoids racing the provider's reset clock.",
149 "minimum": 0,
150 "maximum": MAX_CONTINUATION_DELAY_SECS,
151 "default": DEFAULT_CONTINUATION_DELAY_SECS
152 },
153 "prompt": {
154 "type": "string",
155 "title": "Continuation prompt",
156 "description": "Message injected to resume the interrupted work when the limit resets.",
157 "default": DEFAULT_CONTINUATION_PROMPT
158 }
159 },
160 "additionalProperties": false
161 }))
162 }
163
164 fn validate_config(&self, config: &Value) -> Result<(), String> {
165 if config.is_null() {
166 return Ok(());
167 }
168 if let Some(delay) = config.get("delay_seconds") {
169 let secs = delay
170 .as_i64()
171 .ok_or_else(|| "delay_seconds must be an integer".to_string())?;
172 if !(0..=MAX_CONTINUATION_DELAY_SECS).contains(&secs) {
173 return Err(format!(
174 "delay_seconds must be between 0 and {MAX_CONTINUATION_DELAY_SECS}"
175 ));
176 }
177 }
178 if let Some(prompt) = config.get("prompt")
179 && !prompt.is_string()
180 {
181 return Err("prompt must be a string".to_string());
182 }
183 Ok(())
184 }
185}
186
187struct UsageLimitAutoContinueHook;
191
192#[async_trait]
193impl LlmErrorHook for UsageLimitAutoContinueHook {
194 async fn on_llm_error(&self, ctx: &LlmErrorContext<'_>) -> LlmErrorHookOutcome {
195 if ctx.error_code != crate::user_facing_error_codes::PROVIDER_USAGE_LIMIT_REACHED {
197 return LlmErrorHookOutcome::noop();
198 }
199 let Some(store) = &ctx.services.schedule_store else {
202 return LlmErrorHookOutcome::noop();
203 };
204 let Some(resets_at) = ctx.error_fields.get("resets_at").and_then(|v| v.as_i64()) else {
205 return LlmErrorHookOutcome::noop();
206 };
207 let Some(reset_time) = chrono::DateTime::from_timestamp(resets_at, 0) else {
208 return LlmErrorHookOutcome::noop();
209 };
210
211 let cfg = AutoContinueConfig::from_config_value(ctx.config);
212
213 let now = chrono::Utc::now();
217 let delay = chrono::Duration::seconds(cfg.delay_seconds);
218 let scheduled_at = (reset_time + delay).max(now + delay);
219
220 match store
221 .create_schedule_enforcing_limits(
222 ctx.session_id,
223 cfg.prompt.clone(),
224 None,
225 Some(scheduled_at),
226 "UTC".to_string(),
227 )
228 .await
229 {
230 Ok(schedule) => {
231 tracing::info!(
232 session_id = %ctx.session_id,
233 schedule_id = %schedule.id,
234 scheduled_at = %scheduled_at,
235 "usage_limit_auto_continue: scheduled continuation after usage-limit reset"
236 );
237 LlmErrorHookOutcome::noop().with_error_field("auto_continue", true)
240 }
241 Err(_) => {
242 tracing::warn!(
243 session_id = %ctx.session_id,
244 "usage_limit_auto_continue: failed to schedule continuation within schedule limits"
245 );
246 LlmErrorHookOutcome::noop()
247 }
248 }
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn cap_config(config: Value) -> AgentCapabilityConfig {
257 AgentCapabilityConfig {
258 capability_ref: USAGE_LIMIT_AUTO_CONTINUE_CAPABILITY_ID.into(),
259 config,
260 }
261 }
262
263 #[test]
264 fn resolve_returns_none_without_capability() {
265 assert_eq!(resolve_usage_limit_auto_continue(&[]), None);
266 }
267
268 #[test]
269 fn resolve_uses_defaults_for_empty_config() {
270 let resolved = resolve_usage_limit_auto_continue(&[cap_config(json!({}))]).unwrap();
271 assert_eq!(resolved, AutoContinueConfig::default());
272 }
273
274 #[test]
275 fn resolve_reads_custom_delay_and_prompt() {
276 let resolved = resolve_usage_limit_auto_continue(&[cap_config(json!({
277 "delay_seconds": 300,
278 "prompt": " Resume the migration "
279 }))])
280 .unwrap();
281 assert_eq!(resolved.delay_seconds, 300);
282 assert_eq!(resolved.prompt, "Resume the migration");
283 }
284
285 #[test]
286 fn resolve_falls_back_on_out_of_range_or_blank_fields() {
287 let resolved = resolve_usage_limit_auto_continue(&[cap_config(json!({
288 "delay_seconds": -5,
289 "prompt": " "
290 }))])
291 .unwrap();
292 assert_eq!(resolved.delay_seconds, DEFAULT_CONTINUATION_DELAY_SECS);
293 assert_eq!(resolved.prompt, DEFAULT_CONTINUATION_PROMPT);
294 }
295
296 #[test]
297 fn validate_rejects_bad_types_and_ranges() {
298 let cap = UsageLimitAutoContinueCapability;
299 assert!(
300 cap.validate_config(&json!({ "delay_seconds": 120 }))
301 .is_ok()
302 );
303 assert!(
304 cap.validate_config(&json!({ "delay_seconds": "x" }))
305 .is_err()
306 );
307 assert!(
308 cap.validate_config(&json!({ "delay_seconds": 999999999 }))
309 .is_err()
310 );
311 assert!(cap.validate_config(&json!({ "prompt": 5 })).is_err());
312 }
313
314 use crate::llm_error_hook::{LlmErrorContext, LlmErrorHook, LlmErrorHookServices};
319 use crate::session_schedule::SessionSchedule;
320 use crate::traits::SessionScheduleStore;
321 use crate::typed_id::{PrincipalId, ScheduleId, SessionId};
322 use crate::user_facing_error::UserFacingErrorFields;
323 use crate::user_facing_error_codes;
324 use std::sync::Mutex;
325
326 #[derive(Default)]
329 struct RecordingScheduleStore {
330 created: Mutex<Vec<(String, Option<chrono::DateTime<chrono::Utc>>)>>,
331 active_schedules: u32,
332 }
333
334 #[async_trait]
335 impl SessionScheduleStore for RecordingScheduleStore {
336 async fn create_schedule(
337 &self,
338 session_id: SessionId,
339 description: String,
340 cron_expression: Option<String>,
341 scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
342 timezone: String,
343 ) -> crate::error::Result<SessionSchedule> {
344 self.created
345 .lock()
346 .unwrap()
347 .push((description.clone(), scheduled_at));
348 Ok(SessionSchedule {
349 id: ScheduleId::new(),
350 session_id,
351 owner_principal_id: PrincipalId::new(),
352 resolved_owner_user_id: None,
353 owner: None,
354 effective_owner: None,
355 description,
356 cron_expression: cron_expression.clone(),
357 scheduled_at,
358 timezone,
359 enabled: true,
360 schedule_type: SessionSchedule::derive_type(&cron_expression),
361 next_trigger_at: scheduled_at,
362 last_triggered_at: None,
363 trigger_count: 0,
364 created_at: chrono::Utc::now(),
365 updated_at: chrono::Utc::now(),
366 })
367 }
368
369 async fn cancel_schedule(
370 &self,
371 _session_id: SessionId,
372 _schedule_id: ScheduleId,
373 ) -> crate::error::Result<SessionSchedule> {
374 unimplemented!("not used by these tests")
375 }
376
377 async fn list_schedules(
378 &self,
379 _session_id: SessionId,
380 ) -> crate::error::Result<Vec<SessionSchedule>> {
381 Ok(vec![])
382 }
383
384 async fn count_active_schedules(
385 &self,
386 _session_id: SessionId,
387 ) -> crate::error::Result<u32> {
388 Ok(self.active_schedules)
389 }
390
391 async fn count_active_org_schedules(&self) -> crate::error::Result<u32> {
392 Ok(0)
393 }
394 }
395
396 fn usage_limit_fields(resets_at: i64) -> UserFacingErrorFields {
397 let mut fields = UserFacingErrorFields::new();
398 fields.insert("resets_at".to_string(), json!(resets_at));
399 fields
400 }
401
402 #[tokio::test]
403 async fn handler_schedules_continuation_and_flags_auto_continue() {
404 let store = Arc::new(RecordingScheduleStore::default());
405 let services = LlmErrorHookServices {
406 schedule_store: Some(store.clone()),
407 };
408 let fields = usage_limit_fields(1_783_767_823);
409 let config = json!({ "prompt": "Resume the migration" });
410 let ctx = LlmErrorContext {
411 session_id: SessionId::new(),
412 error_code: user_facing_error_codes::PROVIDER_USAGE_LIMIT_REACHED,
413 error_fields: &fields,
414 config: &config,
415 services: &services,
416 };
417
418 let outcome = UsageLimitAutoContinueHook.on_llm_error(&ctx).await;
419
420 let created = store.created.lock().unwrap();
422 assert_eq!(created.len(), 1);
423 assert_eq!(created[0].0, "Resume the migration");
424 assert!(created[0].1.is_some(), "continuation must have a fire time");
425
426 assert_eq!(
428 outcome.extra_error_fields.get("auto_continue"),
429 Some(&json!(true))
430 );
431 }
432
433 #[tokio::test]
434 async fn handler_enforces_schedule_limits_before_auto_continue() {
435 let store = Arc::new(RecordingScheduleStore {
436 active_schedules: crate::session_schedule::MAX_ACTIVE_SCHEDULES_PER_SESSION,
437 ..Default::default()
438 });
439 let services = LlmErrorHookServices {
440 schedule_store: Some(store.clone()),
441 };
442 let fields = usage_limit_fields(1_783_767_823);
443 let config = json!({});
444 let ctx = LlmErrorContext {
445 session_id: SessionId::new(),
446 error_code: user_facing_error_codes::PROVIDER_USAGE_LIMIT_REACHED,
447 error_fields: &fields,
448 config: &config,
449 services: &services,
450 };
451
452 let outcome = UsageLimitAutoContinueHook.on_llm_error(&ctx).await;
453
454 assert!(store.created.lock().unwrap().is_empty());
455 assert_eq!(outcome, LlmErrorHookOutcome::noop());
456 }
457
458 #[tokio::test]
459 async fn handler_ignores_non_usage_limit_errors() {
460 let store = Arc::new(RecordingScheduleStore::default());
461 let services = LlmErrorHookServices {
462 schedule_store: Some(store.clone()),
463 };
464 let fields = usage_limit_fields(1_783_767_823);
465 let config = json!({});
466 let ctx = LlmErrorContext {
467 session_id: SessionId::new(),
468 error_code: user_facing_error_codes::PROVIDER_RATE_LIMITED,
469 error_fields: &fields,
470 config: &config,
471 services: &services,
472 };
473
474 let outcome = UsageLimitAutoContinueHook.on_llm_error(&ctx).await;
475
476 assert!(store.created.lock().unwrap().is_empty());
477 assert_eq!(outcome, LlmErrorHookOutcome::noop());
478 }
479
480 #[tokio::test]
481 async fn handler_makes_no_promise_without_schedule_store() {
482 let services = LlmErrorHookServices {
483 schedule_store: None,
484 };
485 let fields = usage_limit_fields(1_783_767_823);
486 let config = json!({});
487 let ctx = LlmErrorContext {
488 session_id: SessionId::new(),
489 error_code: user_facing_error_codes::PROVIDER_USAGE_LIMIT_REACHED,
490 error_fields: &fields,
491 config: &config,
492 services: &services,
493 };
494
495 let outcome = UsageLimitAutoContinueHook.on_llm_error(&ctx).await;
496 assert_eq!(outcome, LlmErrorHookOutcome::noop());
497 }
498}