1use super::{Capability, CapabilityLocalization, CapabilityStatus, RiskLevel};
24use crate::llm_error_hook::{LlmErrorContext, LlmErrorHook, LlmErrorHookOutcome};
25use async_trait::async_trait;
26use everruns_capability::CapabilityRef as AgentCapabilityConfig;
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_value()))
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::with_config(USAGE_LIMIT_AUTO_CONTINUE_CAPABILITY_ID, config)
258 }
259
260 #[test]
261 fn resolve_returns_none_without_capability() {
262 assert_eq!(resolve_usage_limit_auto_continue(&[]), None);
263 }
264
265 #[test]
266 fn resolve_uses_defaults_for_empty_config() {
267 let resolved = resolve_usage_limit_auto_continue(&[cap_config(json!({}))]).unwrap();
268 assert_eq!(resolved, AutoContinueConfig::default());
269 }
270
271 #[test]
272 fn resolve_reads_custom_delay_and_prompt() {
273 let resolved = resolve_usage_limit_auto_continue(&[cap_config(json!({
274 "delay_seconds": 300,
275 "prompt": " Resume the migration "
276 }))])
277 .unwrap();
278 assert_eq!(resolved.delay_seconds, 300);
279 assert_eq!(resolved.prompt, "Resume the migration");
280 }
281
282 #[test]
283 fn resolve_falls_back_on_out_of_range_or_blank_fields() {
284 let resolved = resolve_usage_limit_auto_continue(&[cap_config(json!({
285 "delay_seconds": -5,
286 "prompt": " "
287 }))])
288 .unwrap();
289 assert_eq!(resolved.delay_seconds, DEFAULT_CONTINUATION_DELAY_SECS);
290 assert_eq!(resolved.prompt, DEFAULT_CONTINUATION_PROMPT);
291 }
292
293 #[test]
294 fn validate_rejects_bad_types_and_ranges() {
295 let cap = UsageLimitAutoContinueCapability;
296 assert!(
297 cap.validate_config(&json!({ "delay_seconds": 120 }))
298 .is_ok()
299 );
300 assert!(
301 cap.validate_config(&json!({ "delay_seconds": "x" }))
302 .is_err()
303 );
304 assert!(
305 cap.validate_config(&json!({ "delay_seconds": 999999999 }))
306 .is_err()
307 );
308 assert!(cap.validate_config(&json!({ "prompt": 5 })).is_err());
309 }
310
311 use crate::llm_error_hook::{LlmErrorContext, LlmErrorHook, LlmErrorHookServices};
316 use crate::session_schedule::SessionSchedule;
317 use crate::typed_id::{PrincipalId, ScheduleId, SessionId};
318 use crate::user_facing_error::UserFacingErrorFields;
319 use crate::user_facing_error_codes;
320 use everruns_core::session_services::SessionScheduleStore;
321 use std::sync::Mutex;
322
323 #[derive(Default)]
326 struct RecordingScheduleStore {
327 created: Mutex<Vec<(String, Option<chrono::DateTime<chrono::Utc>>)>>,
328 active_schedules: u32,
329 }
330
331 #[async_trait]
332 impl SessionScheduleStore for RecordingScheduleStore {
333 async fn create_schedule(
334 &self,
335 session_id: SessionId,
336 description: String,
337 cron_expression: Option<String>,
338 scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
339 timezone: String,
340 ) -> crate::error::Result<SessionSchedule> {
341 self.created
342 .lock()
343 .unwrap()
344 .push((description.clone(), scheduled_at));
345 Ok(SessionSchedule {
346 id: ScheduleId::new(),
347 session_id,
348 owner_principal_id: PrincipalId::new(),
349 resolved_owner_user_id: None,
350 owner: None,
351 effective_owner: None,
352 description,
353 cron_expression: cron_expression.clone(),
354 scheduled_at,
355 timezone,
356 enabled: true,
357 schedule_type: SessionSchedule::derive_type(&cron_expression),
358 next_trigger_at: scheduled_at,
359 last_triggered_at: None,
360 trigger_count: 0,
361 created_at: chrono::Utc::now(),
362 updated_at: chrono::Utc::now(),
363 })
364 }
365
366 async fn cancel_schedule(
367 &self,
368 _session_id: SessionId,
369 _schedule_id: ScheduleId,
370 ) -> crate::error::Result<SessionSchedule> {
371 unimplemented!("not used by these tests")
372 }
373
374 async fn list_schedules(
375 &self,
376 _session_id: SessionId,
377 ) -> crate::error::Result<Vec<SessionSchedule>> {
378 Ok(vec![])
379 }
380
381 async fn count_active_schedules(
382 &self,
383 _session_id: SessionId,
384 ) -> crate::error::Result<u32> {
385 Ok(self.active_schedules)
386 }
387
388 async fn count_active_org_schedules(&self) -> crate::error::Result<u32> {
389 Ok(0)
390 }
391 }
392
393 fn usage_limit_fields(resets_at: i64) -> UserFacingErrorFields {
394 let mut fields = UserFacingErrorFields::new();
395 fields.insert("resets_at".to_string(), json!(resets_at));
396 fields
397 }
398
399 #[tokio::test]
400 async fn handler_schedules_continuation_and_flags_auto_continue() {
401 let store = Arc::new(RecordingScheduleStore::default());
402 let services = LlmErrorHookServices {
403 schedule_store: Some(store.clone()),
404 };
405 let fields = usage_limit_fields(1_783_767_823);
406 let config = json!({ "prompt": "Resume the migration" });
407 let ctx = LlmErrorContext {
408 session_id: SessionId::new(),
409 error_code: user_facing_error_codes::PROVIDER_USAGE_LIMIT_REACHED,
410 error_fields: &fields,
411 config: &config,
412 services: &services,
413 };
414
415 let outcome = UsageLimitAutoContinueHook.on_llm_error(&ctx).await;
416
417 let created = store.created.lock().unwrap();
419 assert_eq!(created.len(), 1);
420 assert_eq!(created[0].0, "Resume the migration");
421 assert!(created[0].1.is_some(), "continuation must have a fire time");
422
423 assert_eq!(
425 outcome.extra_error_fields.get("auto_continue"),
426 Some(&json!(true))
427 );
428 }
429
430 #[tokio::test]
431 async fn handler_enforces_schedule_limits_before_auto_continue() {
432 let store = Arc::new(RecordingScheduleStore {
433 active_schedules: crate::session_schedule::MAX_ACTIVE_SCHEDULES_PER_SESSION,
434 ..Default::default()
435 });
436 let services = LlmErrorHookServices {
437 schedule_store: Some(store.clone()),
438 };
439 let fields = usage_limit_fields(1_783_767_823);
440 let config = json!({});
441 let ctx = LlmErrorContext {
442 session_id: SessionId::new(),
443 error_code: user_facing_error_codes::PROVIDER_USAGE_LIMIT_REACHED,
444 error_fields: &fields,
445 config: &config,
446 services: &services,
447 };
448
449 let outcome = UsageLimitAutoContinueHook.on_llm_error(&ctx).await;
450
451 assert!(store.created.lock().unwrap().is_empty());
452 assert_eq!(outcome, LlmErrorHookOutcome::noop());
453 }
454
455 #[tokio::test]
456 async fn handler_ignores_non_usage_limit_errors() {
457 let store = Arc::new(RecordingScheduleStore::default());
458 let services = LlmErrorHookServices {
459 schedule_store: Some(store.clone()),
460 };
461 let fields = usage_limit_fields(1_783_767_823);
462 let config = json!({});
463 let ctx = LlmErrorContext {
464 session_id: SessionId::new(),
465 error_code: user_facing_error_codes::PROVIDER_RATE_LIMITED,
466 error_fields: &fields,
467 config: &config,
468 services: &services,
469 };
470
471 let outcome = UsageLimitAutoContinueHook.on_llm_error(&ctx).await;
472
473 assert!(store.created.lock().unwrap().is_empty());
474 assert_eq!(outcome, LlmErrorHookOutcome::noop());
475 }
476
477 #[tokio::test]
478 async fn handler_makes_no_promise_without_schedule_store() {
479 let services = LlmErrorHookServices {
480 schedule_store: None,
481 };
482 let fields = usage_limit_fields(1_783_767_823);
483 let config = json!({});
484 let ctx = LlmErrorContext {
485 session_id: SessionId::new(),
486 error_code: user_facing_error_codes::PROVIDER_USAGE_LIMIT_REACHED,
487 error_fields: &fields,
488 config: &config,
489 services: &services,
490 };
491
492 let outcome = UsageLimitAutoContinueHook.on_llm_error(&ctx).await;
493 assert_eq!(outcome, LlmErrorHookOutcome::noop());
494 }
495}