Skip to main content

everruns_core/capabilities/
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::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
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))
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 {
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    // ------------------------------------------------------------------------
315    // Handler tests (the encapsulated behavior, exercised via the seam)
316    // ------------------------------------------------------------------------
317
318    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    /// Records the one-shot schedules the handler creates so tests can assert on
327    /// the description and fire time.
328    #[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        // Continuation scheduled with the configured prompt.
421        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        // Only now is the auto-continue message copy unlocked.
427        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}