Skip to main content

everruns_builtins/
usage_limit_auto_continue.rs

1// Usage-Limit Auto-Continue capability
2//
3// When an LLM subscription/plan usage limit is hit — classified as
4// `provider_usage_limit_reached` with a concrete `resets_at` timestamp (e.g. the
5// ChatGPT/Codex `usage_limit_reached` body) — this capability makes the session
6// resume on its own once the limit resets.
7//
8// The behavior is fully encapsulated behind the platform's `LlmErrorHook`
9// seam (`crate::llm_error_hook`): the capability contributes no tools and no
10// reason-atom special-casing. It supplies an error hook that, on the terminal
11// error path, when the error code matches:
12//
13//   1. schedules a one-shot session continuation at `resets_at + delay_seconds`
14//      that re-injects `prompt` to resume the interrupted work, and
15//   2. returns an `auto_continue` error field so the user-facing copy promises
16//      automatic resumption.
17//
18// (2) is returned only when (1) actually succeeded, so the copy never
19// over-promises. The reason atom invokes this hook generically alongside any
20// other capability's hook — new error-recovery extensions are built the same
21// way, without touching the atom.
22
23use 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
32/// Default delay, in seconds, after the reported reset time before the
33/// continuation fires. The reset is a lower bound; a small buffer avoids racing
34/// the provider's own clock (the motivating Codex case wants reset + 2 min).
35pub const DEFAULT_CONTINUATION_DELAY_SECS: i64 = 120;
36
37/// Default prompt re-injected to resume work once the limit resets.
38pub const DEFAULT_CONTINUATION_PROMPT: &str = "Continue tasks";
39
40/// Upper bound on the configurable delay (24h) so a misconfiguration cannot park
41/// a continuation arbitrarily far in the future.
42const MAX_CONTINUATION_DELAY_SECS: i64 = 86_400;
43
44/// Resolved auto-continue settings for a turn.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct AutoContinueConfig {
47    /// Seconds to wait after `resets_at` before firing the continuation.
48    pub delay_seconds: i64,
49    /// Prompt injected as the continuation turn's user message.
50    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    /// Parse settings from a single capability config JSON value, falling back
64    /// to defaults for any missing, blank, or out-of-range field.
65    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
87/// Resolve the auto-continue settings for a turn, or `None` when the capability
88/// is not enabled for the agent/session.
89pub 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
187/// Error hook that encapsulates the auto-continue behavior. Invoked by the
188/// reason atom through the generic `LlmErrorHook` seam on a terminal LLM
189/// error; the reason atom itself has no knowledge of usage limits or scheduling.
190struct UsageLimitAutoContinueHook;
191
192#[async_trait]
193impl LlmErrorHook for UsageLimitAutoContinueHook {
194    async fn on_llm_error(&self, ctx: &LlmErrorContext<'_>) -> LlmErrorHookOutcome {
195        // Only act on the usage-limit error this capability recovers from.
196        if ctx.error_code != crate::user_facing_error_codes::PROVIDER_USAGE_LIMIT_REACHED {
197            return LlmErrorHookOutcome::noop();
198        }
199        // Best-effort: without a schedule store or a concrete reset time there
200        // is nothing to schedule, so make no auto-resume promise.
201        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        // Fire `delay_seconds` after the reset. Clamp into the future so a
214        // stale/past reset (clock skew, limit already cleared) still fires
215        // instead of being dropped by the store's past-time next-trigger rule.
216        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                // Unlock the "we'll continue automatically" message copy only
238                // now that a continuation was actually scheduled.
239                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    // ------------------------------------------------------------------------
312    // Handler tests (the encapsulated behavior, exercised via the seam)
313    // ------------------------------------------------------------------------
314
315    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    /// Records the one-shot schedules the handler creates so tests can assert on
324    /// the description and fire time.
325    #[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        // Continuation scheduled with the configured prompt.
418        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        // Only now is the auto-continue message copy unlocked.
424        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}