1use 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
17pub 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
72pub 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
193pub 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
272pub 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 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 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 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 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 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 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 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 }