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