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 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
208pub 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
302pub 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 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 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 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 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 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 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 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 }