Skip to main content

everruns_core/capabilities/
session_schedule.rs

1//! Session Schedule Capability
2//!
3//! Provides tools for scheduling future work within a session:
4//! - `create_schedule`: Schedule a task (one-shot or recurring cron)
5//! - `cancel_schedule`: Cancel/disable a schedule
6//! - `list_schedules`: List all schedules for the current session
7
8use super::{Capability, CapabilityLocalization, CapabilityStatus};
9use crate::tool_types::ToolHints;
10use crate::tools::{Tool, ToolExecutionResult};
11use crate::traits::ToolContext;
12use async_trait::async_trait;
13use serde_json::{Value, json};
14
15pub const SESSION_SCHEDULE_CAPABILITY_ID: &str = "session_schedule";
16
17/// Session schedule capability — lets the agent schedule future work.
18pub struct SessionScheduleCapability;
19
20impl Capability for SessionScheduleCapability {
21    fn id(&self) -> &str {
22        SESSION_SCHEDULE_CAPABILITY_ID
23    }
24
25    fn name(&self) -> &str {
26        "Schedules"
27    }
28
29    fn description(&self) -> &str {
30        "Schedule future tasks within the current session. Supports one-shot and recurring (cron) schedules."
31    }
32
33    fn localizations(&self) -> Vec<CapabilityLocalization> {
34        vec![CapabilityLocalization::text(
35            "uk",
36            "Розклади",
37            "Плануйте майбутні завдання в межах поточної сесії. Підтримує одноразові та повторювані (cron) розклади.",
38        )]
39    }
40
41    fn status(&self) -> CapabilityStatus {
42        CapabilityStatus::Available
43    }
44
45    fn icon(&self) -> Option<&str> {
46        Some("clock")
47    }
48
49    fn category(&self) -> Option<&str> {
50        Some("Core")
51    }
52
53    fn system_prompt_addition(&self) -> Option<&str> {
54        Some(
55            "When a schedule fires, you will receive a message with the task description and should execute it. Maximum 5 active schedules per session.",
56        )
57    }
58
59    fn tools(&self) -> Vec<Box<dyn Tool>> {
60        vec![
61            Box::new(CreateScheduleTool),
62            Box::new(CancelScheduleTool),
63            Box::new(ListSchedulesTool),
64        ]
65    }
66
67    fn features(&self) -> Vec<&'static str> {
68        vec!["schedules"]
69    }
70}
71
72// ============================================================================
73// create_schedule tool
74// ============================================================================
75
76pub struct CreateScheduleTool;
77
78#[async_trait]
79impl Tool for CreateScheduleTool {
80    fn name(&self) -> &str {
81        "create_schedule"
82    }
83
84    fn display_name(&self) -> Option<&str> {
85        Some("Create Schedule")
86    }
87
88    fn description(&self) -> &str {
89        "Schedule a future task in this session. Provide description and either scheduled_at (one-shot) or cron_expression (recurring)."
90    }
91
92    fn parameters_schema(&self) -> Value {
93        json!({
94            "type": "object",
95            "properties": {
96                "description": {
97                    "type": "string",
98                    "description": "What the agent should do when the schedule fires"
99                },
100                "cron_expression": {
101                    "type": "string",
102                    "description": "Standard 5-field cron expression for recurring schedules (e.g., '0 3 * * *' for daily at 3am)"
103                },
104                "scheduled_at": {
105                    "type": "string",
106                    "description": "ISO 8601 datetime for one-shot schedule (e.g., '2026-02-19T03:00:00Z')"
107                },
108                "timezone": {
109                    "type": "string",
110                    "description": "IANA timezone (e.g., 'America/New_York'). Default: UTC"
111                }
112            },
113            "required": ["description"],
114            "additionalProperties": false
115        })
116    }
117
118    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
119        ToolExecutionResult::tool_error(
120            "create_schedule requires context. This tool must be executed with session context.",
121        )
122    }
123
124    async fn execute_with_context(
125        &self,
126        arguments: Value,
127        context: &ToolContext,
128    ) -> ToolExecutionResult {
129        let description = match arguments.get("description").and_then(|v| v.as_str()) {
130            Some(d) if !d.trim().is_empty() => d.trim().to_string(),
131            _ => return ToolExecutionResult::tool_error("Missing required parameter: description"),
132        };
133
134        let cron_expression = arguments
135            .get("cron_expression")
136            .and_then(|v| v.as_str())
137            .map(|s| s.trim().to_string())
138            .filter(|s| !s.is_empty());
139
140        let scheduled_at = arguments
141            .get("scheduled_at")
142            .and_then(|v| v.as_str())
143            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
144            .map(|dt| dt.with_timezone(&chrono::Utc));
145
146        if cron_expression.is_none() && scheduled_at.is_none() {
147            return ToolExecutionResult::tool_error(
148                "Must provide either cron_expression (recurring) or scheduled_at (one-shot)",
149            );
150        }
151
152        let timezone = arguments
153            .get("timezone")
154            .and_then(|v| v.as_str())
155            .unwrap_or("UTC")
156            .to_string();
157
158        let Some(store) = &context.schedule_store else {
159            return ToolExecutionResult::tool_error("Schedule store not available in this context");
160        };
161
162        match store
163            .create_schedule_enforcing_limits(
164                context.session_id,
165                description,
166                cron_expression,
167                scheduled_at,
168                timezone,
169            )
170            .await
171        {
172            Ok(schedule) => ToolExecutionResult::success(json!({
173                "schedule_id": schedule.id.to_string(),
174                "description": schedule.description,
175                "schedule_type": schedule.schedule_type,
176                "cron_expression": schedule.cron_expression,
177                "scheduled_at": schedule.scheduled_at,
178                "timezone": schedule.timezone,
179                "next_trigger_at": schedule.next_trigger_at,
180                "enabled": schedule.enabled,
181                "created": true,
182            })),
183            Err(crate::session_schedule::ScheduleLimitError::Store(e)) => {
184                ToolExecutionResult::internal_error(e)
185            }
186            Err(crate::session_schedule::ScheduleLimitError::Rejected(msg)) => {
187                ToolExecutionResult::tool_error(msg)
188            }
189        }
190    }
191}
192
193// ============================================================================
194// cancel_schedule tool
195// ============================================================================
196
197pub struct CancelScheduleTool;
198
199#[async_trait]
200impl Tool for CancelScheduleTool {
201    fn name(&self) -> &str {
202        "cancel_schedule"
203    }
204
205    fn display_name(&self) -> Option<&str> {
206        Some("Cancel Schedule")
207    }
208
209    fn description(&self) -> &str {
210        "Cancel (disable) an active schedule by its ID."
211    }
212
213    fn parameters_schema(&self) -> Value {
214        json!({
215            "type": "object",
216            "properties": {
217                "schedule_id": {
218                    "type": "string",
219                    "description": "The schedule ID to cancel (e.g., 'sched_...')"
220                }
221            },
222            "required": ["schedule_id"],
223            "additionalProperties": false
224        })
225    }
226
227    fn hints(&self) -> ToolHints {
228        ToolHints::default().with_destructive(true)
229    }
230
231    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
232        ToolExecutionResult::tool_error(
233            "cancel_schedule requires context. This tool must be executed with session context.",
234        )
235    }
236
237    async fn execute_with_context(
238        &self,
239        arguments: Value,
240        context: &ToolContext,
241    ) -> ToolExecutionResult {
242        let schedule_id_str = match arguments.get("schedule_id").and_then(|v| v.as_str()) {
243            Some(s) if !s.trim().is_empty() => s.trim(),
244            _ => return ToolExecutionResult::tool_error("Missing required parameter: schedule_id"),
245        };
246
247        let schedule_id = match schedule_id_str.parse::<crate::typed_id::ScheduleId>() {
248            Ok(id) => id,
249            Err(_) => {
250                return ToolExecutionResult::tool_error(format!(
251                    "Invalid schedule_id format: {schedule_id_str}"
252                ));
253            }
254        };
255
256        let Some(store) = &context.schedule_store else {
257            return ToolExecutionResult::tool_error("Schedule store not available in this context");
258        };
259
260        match store.cancel_schedule(context.session_id, schedule_id).await {
261            Ok(schedule) => ToolExecutionResult::success(json!({
262                "schedule_id": schedule.id.to_string(),
263                "description": schedule.description,
264                "enabled": schedule.enabled,
265                "cancelled": true,
266            })),
267            Err(e) => ToolExecutionResult::internal_error(e),
268        }
269    }
270}
271
272// ============================================================================
273// list_schedules tool
274// ============================================================================
275
276pub struct ListSchedulesTool;
277
278#[async_trait]
279impl Tool for ListSchedulesTool {
280    fn name(&self) -> &str {
281        "list_schedules"
282    }
283
284    fn display_name(&self) -> Option<&str> {
285        Some("List Schedules")
286    }
287
288    fn description(&self) -> &str {
289        "List all schedules for the current session."
290    }
291
292    fn parameters_schema(&self) -> Value {
293        json!({
294            "type": "object",
295            "properties": {},
296            "additionalProperties": false
297        })
298    }
299
300    fn hints(&self) -> ToolHints {
301        ToolHints::default()
302            .with_readonly(true)
303            .with_idempotent(true)
304    }
305
306    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
307        ToolExecutionResult::tool_error(
308            "list_schedules requires context. This tool must be executed with session context.",
309        )
310    }
311
312    async fn execute_with_context(
313        &self,
314        _arguments: Value,
315        context: &ToolContext,
316    ) -> ToolExecutionResult {
317        let Some(store) = &context.schedule_store else {
318            return ToolExecutionResult::tool_error("Schedule store not available in this context");
319        };
320
321        match store.list_schedules(context.session_id).await {
322            Ok(schedules) => {
323                let items: Vec<Value> = schedules
324                    .iter()
325                    .map(|s| {
326                        json!({
327                            "schedule_id": s.id.to_string(),
328                            "description": s.description,
329                            "schedule_type": s.schedule_type,
330                            "cron_expression": s.cron_expression,
331                            "scheduled_at": s.scheduled_at,
332                            "timezone": s.timezone,
333                            "enabled": s.enabled,
334                            "next_trigger_at": s.next_trigger_at,
335                            "last_triggered_at": s.last_triggered_at,
336                            "trigger_count": s.trigger_count,
337                        })
338                    })
339                    .collect();
340
341                ToolExecutionResult::success(json!({
342                    "schedules": items,
343                    "total": schedules.len(),
344                }))
345            }
346            Err(e) => ToolExecutionResult::internal_error(e),
347        }
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::session_schedule::SessionSchedule;
355    use crate::traits::SessionScheduleStore;
356    use crate::typed_id::{ScheduleId, SessionId};
357    use async_trait::async_trait;
358    use chrono::Utc;
359    use std::sync::{Arc, Mutex};
360
361    #[derive(Clone)]
362    struct MockScheduleStore {
363        schedules: Arc<Mutex<Vec<SessionSchedule>>>,
364    }
365
366    impl MockScheduleStore {
367        fn new() -> Self {
368            Self {
369                schedules: Arc::new(Mutex::new(Vec::new())),
370            }
371        }
372    }
373
374    #[async_trait]
375    impl SessionScheduleStore for MockScheduleStore {
376        async fn create_schedule(
377            &self,
378            session_id: SessionId,
379            description: String,
380            cron_expression: Option<String>,
381            scheduled_at: Option<chrono::DateTime<Utc>>,
382            timezone: String,
383        ) -> crate::error::Result<SessionSchedule> {
384            let schedule = SessionSchedule {
385                id: ScheduleId::new(),
386                session_id,
387                owner_principal_id: crate::PrincipalId::from_seed(1),
388                resolved_owner_user_id: None,
389                owner: None,
390                effective_owner: None,
391                description,
392                schedule_type: SessionSchedule::derive_type(&cron_expression),
393                cron_expression,
394                scheduled_at,
395                timezone,
396                enabled: true,
397                next_trigger_at: Some(Utc::now() + chrono::Duration::hours(1)),
398                last_triggered_at: None,
399                trigger_count: 0,
400                created_at: Utc::now(),
401                updated_at: Utc::now(),
402            };
403            self.schedules.lock().unwrap().push(schedule.clone());
404            Ok(schedule)
405        }
406
407        async fn cancel_schedule(
408            &self,
409            _session_id: SessionId,
410            schedule_id: ScheduleId,
411        ) -> crate::error::Result<SessionSchedule> {
412            let mut schedules = self.schedules.lock().unwrap();
413            let schedule = schedules
414                .iter_mut()
415                .find(|s| s.id == schedule_id)
416                .ok_or_else(|| crate::error::AgentLoopError::tool("Schedule not found"))?;
417            schedule.enabled = false;
418            Ok(schedule.clone())
419        }
420
421        async fn list_schedules(
422            &self,
423            session_id: SessionId,
424        ) -> crate::error::Result<Vec<SessionSchedule>> {
425            let schedules = self.schedules.lock().unwrap();
426            Ok(schedules
427                .iter()
428                .filter(|s| s.session_id == session_id)
429                .cloned()
430                .collect())
431        }
432
433        async fn count_active_schedules(&self, session_id: SessionId) -> crate::error::Result<u32> {
434            let schedules = self.schedules.lock().unwrap();
435            Ok(schedules
436                .iter()
437                .filter(|s| s.session_id == session_id && s.enabled)
438                .count() as u32)
439        }
440
441        async fn count_active_org_schedules(&self) -> crate::error::Result<u32> {
442            let schedules = self.schedules.lock().unwrap();
443            Ok(schedules.iter().filter(|s| s.enabled).count() as u32)
444        }
445    }
446
447    #[tokio::test]
448    async fn create_schedule_one_shot() {
449        let store = MockScheduleStore::new();
450        let session_id = SessionId::new();
451        let mut context = ToolContext::new(session_id);
452        context.schedule_store = Some(Arc::new(store));
453
454        let tool = CreateScheduleTool;
455        let result = tool
456            .execute_with_context(
457                json!({
458                    "description": "Run backup",
459                    "scheduled_at": "2026-02-19T03:00:00Z"
460                }),
461                &context,
462            )
463            .await;
464
465        match result {
466            ToolExecutionResult::Success(value) => {
467                assert_eq!(value["created"], true);
468                assert_eq!(value["description"], "Run backup");
469                assert_eq!(value["schedule_type"], "oneshot");
470            }
471            other => panic!("expected success, got: {other:?}"),
472        }
473    }
474
475    #[tokio::test]
476    async fn create_schedule_recurring() {
477        let store = MockScheduleStore::new();
478        let session_id = SessionId::new();
479        let mut context = ToolContext::new(session_id);
480        context.schedule_store = Some(Arc::new(store));
481
482        let tool = CreateScheduleTool;
483        let result = tool
484            .execute_with_context(
485                json!({
486                    "description": "Check logs",
487                    "cron_expression": "0 3 * * *"
488                }),
489                &context,
490            )
491            .await;
492
493        match result {
494            ToolExecutionResult::Success(value) => {
495                assert_eq!(value["created"], true);
496                assert_eq!(value["schedule_type"], "recurring");
497                assert_eq!(value["cron_expression"], "0 3 * * *");
498            }
499            other => panic!("expected success, got: {other:?}"),
500        }
501    }
502
503    #[tokio::test]
504    async fn create_schedule_rejects_missing_time() {
505        let store = MockScheduleStore::new();
506        let session_id = SessionId::new();
507        let mut context = ToolContext::new(session_id);
508        context.schedule_store = Some(Arc::new(store));
509
510        let tool = CreateScheduleTool;
511        let result = tool
512            .execute_with_context(json!({"description": "No time"}), &context)
513            .await;
514
515        assert!(matches!(result, ToolExecutionResult::ToolError(_)));
516    }
517
518    #[tokio::test]
519    async fn create_schedule_enforces_max_limit() {
520        let store = MockScheduleStore::new();
521        let session_id = SessionId::new();
522        let mut context = ToolContext::new(session_id);
523        context.schedule_store = Some(Arc::new(store.clone()));
524
525        let tool = CreateScheduleTool;
526
527        // Create 5 schedules
528        for i in 0..5 {
529            let result = tool
530                .execute_with_context(
531                    json!({
532                        "description": format!("Task {i}"),
533                        "scheduled_at": "2026-12-01T00:00:00Z"
534                    }),
535                    &context,
536                )
537                .await;
538            assert!(matches!(result, ToolExecutionResult::Success(_)));
539        }
540
541        // 6th should fail
542        let result = tool
543            .execute_with_context(
544                json!({
545                    "description": "Task 6",
546                    "scheduled_at": "2026-12-01T00:00:00Z"
547                }),
548                &context,
549            )
550            .await;
551        assert!(matches!(result, ToolExecutionResult::ToolError(_)));
552    }
553
554    #[tokio::test]
555    async fn create_schedule_rejects_frequent_cron() {
556        // Uses the default minimum interval (300s); the guard restores whatever
557        // value the suite was launched with.
558        let _g =
559            crate::session_schedule::EnvVarGuard::unset("SESSION_SCHEDULE_MIN_INTERVAL_SECONDS");
560        let store = MockScheduleStore::new();
561        let session_id = SessionId::new();
562        let mut context = ToolContext::new(session_id);
563        context.schedule_store = Some(Arc::new(store));
564
565        let tool = CreateScheduleTool;
566        let result = tool
567            .execute_with_context(
568                json!({
569                    "description": "Too frequent",
570                    "cron_expression": "* * * * *"
571                }),
572                &context,
573            )
574            .await;
575
576        match result {
577            ToolExecutionResult::ToolError(msg) => {
578                assert!(msg.contains("no more than once"), "unexpected msg: {msg}");
579            }
580            other => panic!("expected tool error, got: {other:?}"),
581        }
582    }
583
584    #[tokio::test]
585    async fn create_schedule_enforces_per_org_cap() {
586        // Uses the DEFAULT per-org cap rather than lowering it, so we don't set a
587        // value other parallel CreateScheduleTool tests would read mid-run; the
588        // guard still restores any externally-set value on drop. Fill the cap
589        // across many sessions (5 each, under the per-session cap) so the per-org
590        // cap, not the per-session cap, is what rejects the final create.
591        let _g = crate::session_schedule::EnvVarGuard::unset(
592            "RESOURCE_LIMIT_MAX_SESSION_SCHEDULES_PER_ORG",
593        );
594        let cap = crate::session_schedule::DEFAULT_MAX_SCHEDULES_PER_ORG as usize;
595
596        let store = MockScheduleStore::new();
597        let tool = CreateScheduleTool;
598        let one_shot = json!({"description": "x", "scheduled_at": "2026-12-01T00:00:00Z"});
599
600        let mut created = 0usize;
601        while created < cap {
602            let mut ctx = ToolContext::new(SessionId::new());
603            ctx.schedule_store = Some(Arc::new(store.clone()));
604            for _ in 0..crate::session_schedule::MAX_ACTIVE_SCHEDULES_PER_SESSION {
605                if created >= cap {
606                    break;
607                }
608                let r = tool.execute_with_context(one_shot.clone(), &ctx).await;
609                assert!(
610                    matches!(r, ToolExecutionResult::Success(_)),
611                    "create #{created} should succeed, got {r:?}"
612                );
613                created += 1;
614            }
615        }
616
617        // Org is at the cap; a create in a fresh session is rejected org-wide even
618        // though that session is empty (well under the per-session cap).
619        let mut ctx = ToolContext::new(SessionId::new());
620        ctx.schedule_store = Some(Arc::new(store.clone()));
621        match tool.execute_with_context(one_shot, &ctx).await {
622            ToolExecutionResult::ToolError(msg) => {
623                assert!(msg.contains("per org"), "unexpected msg: {msg}");
624            }
625            other => panic!("expected tool error, got: {other:?}"),
626        }
627    }
628
629    #[tokio::test]
630    async fn cancel_schedule_works() {
631        let store = MockScheduleStore::new();
632        let session_id = SessionId::new();
633        let mut context = ToolContext::new(session_id);
634        context.schedule_store = Some(Arc::new(store.clone()));
635
636        // Create a schedule first
637        let schedule = store
638            .create_schedule(
639                session_id,
640                "test".to_string(),
641                None,
642                Some(Utc::now() + chrono::Duration::hours(1)),
643                "UTC".to_string(),
644            )
645            .await
646            .unwrap();
647
648        let tool = CancelScheduleTool;
649        let result = tool
650            .execute_with_context(json!({"schedule_id": schedule.id.to_string()}), &context)
651            .await;
652
653        match result {
654            ToolExecutionResult::Success(value) => {
655                assert_eq!(value["cancelled"], true);
656                assert_eq!(value["enabled"], false);
657            }
658            other => panic!("expected success, got: {other:?}"),
659        }
660    }
661
662    #[tokio::test]
663    async fn list_schedules_works() {
664        let store = MockScheduleStore::new();
665        let session_id = SessionId::new();
666        let mut context = ToolContext::new(session_id);
667        context.schedule_store = Some(Arc::new(store.clone()));
668
669        // Create 2 schedules
670        store
671            .create_schedule(
672                session_id,
673                "first".to_string(),
674                None,
675                Some(Utc::now() + chrono::Duration::hours(1)),
676                "UTC".to_string(),
677            )
678            .await
679            .unwrap();
680        store
681            .create_schedule(
682                session_id,
683                "second".to_string(),
684                Some("0 * * * *".to_string()),
685                None,
686                "UTC".to_string(),
687            )
688            .await
689            .unwrap();
690
691        let tool = ListSchedulesTool;
692        let result = tool.execute_with_context(json!({}), &context).await;
693
694        match result {
695            ToolExecutionResult::Success(value) => {
696                assert_eq!(value["total"], 2);
697                assert_eq!(value["schedules"].as_array().unwrap().len(), 2);
698            }
699            other => panic!("expected success, got: {other:?}"),
700        }
701    }
702
703    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
704}