1use std::collections::HashMap;
12use std::fmt;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::Duration;
16
17use async_trait::async_trait;
18use talos_core::session::{
19 MAX_SUBMISSION_ITEM_BYTES, SessionOp, StructuredSubmission, SubmissionItem, SubmissionKind,
20 SubmissionReceipt, SubmissionReceiptDisposition, SubmissionSource,
21};
22use talos_core::tool::{AgentTool, ToolFamily, ToolNature, ToolResult};
23use tokio::sync::{mpsc, oneshot};
24use tokio::task::JoinHandle;
25use tokio::time::Instant;
26use tokio_util::sync::CancellationToken;
27
28pub(crate) const MIN_DELAY_SECS: u64 = 1;
30pub(crate) const MAX_DELAY_SECS: u64 = 86_400;
32pub(crate) const MIN_INTERVAL_SECS: u64 = 5;
34pub(crate) const MAX_INTERVAL_SECS: u64 = 3_600;
36
37const SCHEDULER_COMMAND_CAPACITY: usize = 64;
38const DELIVERY_SEND_TIMEOUT: Duration = Duration::from_millis(250);
39const DELIVERY_RECEIPT_TIMEOUT: Duration = Duration::from_secs(1);
40const DELIVERY_RETRY_DELAY: Duration = Duration::from_secs(1);
41
42pub(crate) const SCHEDULED_FOLLOWUP_LABEL: &str = "[scheduled-followup]";
44
45static NEXT_TASK_SEQ: AtomicU64 = AtomicU64::new(1);
46static NEXT_DELIVERY_SEQ: AtomicU64 = AtomicU64::new(1);
47
48pub(crate) fn validate_delay_secs(delay_secs: u64) -> Result<(), String> {
49 if delay_secs < MIN_DELAY_SECS {
50 return Err(format!(
51 "delay_secs must be at least {MIN_DELAY_SECS}; got {delay_secs}"
52 ));
53 }
54 if delay_secs > MAX_DELAY_SECS {
55 return Err(format!(
56 "delay_secs must be at most {MAX_DELAY_SECS}; got {delay_secs}"
57 ));
58 }
59 Ok(())
60}
61
62pub(crate) fn validate_interval_secs(interval_secs: u64) -> Result<(), String> {
63 if interval_secs < MIN_INTERVAL_SECS {
64 return Err(format!(
65 "interval_secs must be at least {MIN_INTERVAL_SECS}; got {interval_secs}"
66 ));
67 }
68 if interval_secs > MAX_INTERVAL_SECS {
69 return Err(format!(
70 "interval_secs must be at most {MAX_INTERVAL_SECS}; got {interval_secs}"
71 ));
72 }
73 Ok(())
74}
75
76pub(crate) fn label_scheduled_message(message: &str) -> String {
77 format!("{SCHEDULED_FOLLOWUP_LABEL} {message}")
78}
79
80fn validate_scheduled_message(message: &str) -> Result<String, String> {
81 if message.is_empty() {
82 return Err("message must not be empty".into());
83 }
84 let labeled = label_scheduled_message(message);
85 if labeled.len() > MAX_SUBMISSION_ITEM_BYTES {
86 return Err(format!(
87 "message exceeds the structured submission item limit of {MAX_SUBMISSION_ITEM_BYTES} UTF-8 bytes"
88 ));
89 }
90 Ok(labeled)
91}
92
93pub(crate) fn next_task_id() -> String {
94 format!("sched_{}", NEXT_TASK_SEQ.fetch_add(1, Ordering::Relaxed))
95}
96
97fn next_delivery_identity(task_id: &str) -> String {
98 let registration = NEXT_DELIVERY_SEQ.fetch_add(1, Ordering::Relaxed);
99 format!("scheduler:{task_id}:registration:{registration}")
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub(crate) enum ScheduleKind {
104 OneShot,
105 Recurring { interval: Duration },
106}
107
108impl fmt::Display for ScheduleKind {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 match self {
111 Self::OneShot => write!(f, "one-shot"),
112 Self::Recurring { interval } => write!(f, "recurring ({}s)", interval.as_secs()),
113 }
114 }
115}
116
117#[derive(Debug, Clone)]
118#[allow(dead_code)]
119pub(crate) struct ScheduledTaskInfo {
120 pub id: String,
121 pub message: String,
122 pub kind: ScheduleKind,
123 pub created_at: Instant,
124 pub fire_at: Instant,
125}
126
127impl ScheduledTaskInfo {
128 #[must_use]
129 pub fn remaining(&self) -> Duration {
130 self.fire_at.saturating_duration_since(Instant::now())
131 }
132
133 #[must_use]
134 fn delivery_state(&self) -> &'static str {
135 if self.remaining().is_zero() {
136 "delivery-blocked"
137 } else {
138 "scheduled"
139 }
140 }
141}
142
143#[derive(Debug)]
144pub(crate) enum ScheduleRegistrationResult {
145 Registered { task_id: String },
146 InvalidDuration { reason: String },
147 InvalidMessage { reason: String },
148}
149
150#[derive(Debug)]
151pub(crate) enum CancelResult {
152 Cancelled,
153 NotFound,
154}
155
156#[derive(Debug)]
157#[allow(dead_code)]
158pub(crate) enum ScheduleCommand {
159 RegisterOneShot {
160 id: Option<String>,
161 message: String,
162 delay: Duration,
163 response_tx: oneshot::Sender<ScheduleRegistrationResult>,
164 },
165 RegisterRecurring {
166 id: Option<String>,
167 message: String,
168 interval: Duration,
169 response_tx: oneshot::Sender<ScheduleRegistrationResult>,
170 },
171 Cancel {
172 id: String,
173 response_tx: oneshot::Sender<CancelResult>,
174 },
175 List {
176 response_tx: oneshot::Sender<Vec<ScheduledTaskInfo>>,
177 },
178 Shutdown,
179}
180
181#[derive(Clone)]
182pub(crate) struct SchedulerHandle {
183 cmd_tx: mpsc::Sender<ScheduleCommand>,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub(crate) enum SchedulerSendError {
188 Full,
189 Closed,
190}
191
192impl SchedulerHandle {
193 pub(crate) fn new(cmd_tx: mpsc::Sender<ScheduleCommand>) -> Self {
194 Self { cmd_tx }
195 }
196
197 pub(crate) async fn send(&self, command: ScheduleCommand) -> Result<(), SchedulerSendError> {
198 match self.cmd_tx.try_send(command) {
199 Ok(()) => Ok(()),
200 Err(mpsc::error::TrySendError::Full(_)) => Err(SchedulerSendError::Full),
201 Err(mpsc::error::TrySendError::Closed(_)) => Err(SchedulerSendError::Closed),
202 }
203 }
204}
205
206impl fmt::Debug for SchedulerHandle {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 f.debug_struct("SchedulerHandle")
209 .field("cmd_tx", &"mpsc::Sender<ScheduleCommand>")
210 .finish()
211 }
212}
213
214struct ActiveTask {
215 info: ScheduledTaskInfo,
216 handle: JoinHandle<()>,
217}
218
219struct AcceptedFire {
220 task_id: String,
221 next_fire_at: Option<Instant>,
222}
223
224pub(crate) struct SchedulerActor {
225 cmd_rx: mpsc::Receiver<ScheduleCommand>,
226 sq_tx: mpsc::Sender<SessionOp>,
227 session_generation: u64,
228 cancel_token: CancellationToken,
229 tasks: HashMap<String, ActiveTask>,
230 accepted_tx: mpsc::UnboundedSender<AcceptedFire>,
231 accepted_rx: mpsc::UnboundedReceiver<AcceptedFire>,
232}
233
234impl SchedulerActor {
235 pub(crate) fn new(
236 cmd_rx: mpsc::Receiver<ScheduleCommand>,
237 sq_tx: mpsc::Sender<SessionOp>,
238 session_generation: u64,
239 cancel_token: CancellationToken,
240 ) -> Self {
241 let (accepted_tx, accepted_rx) = mpsc::unbounded_channel();
242 Self {
243 cmd_rx,
244 sq_tx,
245 session_generation,
246 cancel_token,
247 tasks: HashMap::new(),
248 accepted_tx,
249 accepted_rx,
250 }
251 }
252
253 pub(crate) async fn run(mut self) {
254 loop {
255 tokio::select! {
256 biased;
257
258 _ = self.cancel_token.cancelled() => break,
259
260 Some(accepted) = self.accepted_rx.recv() => {
261 match accepted.next_fire_at {
262 None => {
263 self.tasks.remove(&accepted.task_id);
264 }
265 Some(next_fire_at) => {
266 if let Some(task) = self.tasks.get_mut(&accepted.task_id) {
267 task.info.fire_at = next_fire_at;
268 }
269 }
270 }
271 }
272
273 command = self.cmd_rx.recv() => {
274 match command {
275 Some(ScheduleCommand::RegisterOneShot {
276 id,
277 message,
278 delay,
279 response_tx,
280 }) => self.handle_register_one_shot(id, message, delay, response_tx),
281 Some(ScheduleCommand::RegisterRecurring {
282 id,
283 message,
284 interval,
285 response_tx,
286 }) => self.handle_register_recurring(
287 id,
288 message,
289 interval,
290 response_tx,
291 ),
292 Some(ScheduleCommand::Cancel { id, response_tx }) => {
293 self.handle_cancel(id, response_tx);
294 }
295 Some(ScheduleCommand::List { response_tx }) => {
296 self.handle_list(response_tx);
297 }
298 Some(ScheduleCommand::Shutdown) | None => break,
299 }
300 }
301 }
302 }
303
304 for (_, task) in self.tasks.drain() {
305 task.handle.abort();
306 }
307 }
308
309 fn handle_register_one_shot(
310 &mut self,
311 id: Option<String>,
312 message: String,
313 delay: Duration,
314 response_tx: oneshot::Sender<ScheduleRegistrationResult>,
315 ) {
316 if let Err(reason) = validate_delay_secs(delay.as_secs()) {
317 let _ = response_tx.send(ScheduleRegistrationResult::InvalidDuration { reason });
318 return;
319 }
320 let labeled_message = match validate_scheduled_message(&message) {
321 Ok(message) => message,
322 Err(reason) => {
323 let _ = response_tx.send(ScheduleRegistrationResult::InvalidMessage { reason });
324 return;
325 }
326 };
327
328 let task_id = id.unwrap_or_else(next_task_id);
329 if self.tasks.contains_key(&task_id) {
330 let _ = response_tx.send(ScheduleRegistrationResult::InvalidMessage {
331 reason: format!("task ID {task_id} is already active"),
332 });
333 return;
334 }
335 let delivery_identity = next_delivery_identity(&task_id);
336 let now = Instant::now();
337 let sq_tx = self.sq_tx.clone();
338 let session_generation = self.session_generation;
339 let accepted_tx = self.accepted_tx.clone();
340 let task_id_for_fire = task_id.clone();
341 let message_for_fire = labeled_message.clone();
342
343 let handle = tokio::spawn(async move {
344 tokio::time::sleep(delay).await;
345 let submission =
346 scheduled_submission(&delivery_identity, 1, session_generation, message_for_fire);
347 deliver_until_accepted(&sq_tx, submission).await;
348 let _ = accepted_tx.send(AcceptedFire {
349 task_id: task_id_for_fire,
350 next_fire_at: None,
351 });
352 });
353
354 self.tasks.insert(
355 task_id.clone(),
356 ActiveTask {
357 info: ScheduledTaskInfo {
358 id: task_id.clone(),
359 message: labeled_message,
360 kind: ScheduleKind::OneShot,
361 created_at: now,
362 fire_at: now + delay,
363 },
364 handle,
365 },
366 );
367 let _ = response_tx.send(ScheduleRegistrationResult::Registered { task_id });
368 }
369
370 fn handle_register_recurring(
371 &mut self,
372 id: Option<String>,
373 message: String,
374 interval: Duration,
375 response_tx: oneshot::Sender<ScheduleRegistrationResult>,
376 ) {
377 if let Err(reason) = validate_interval_secs(interval.as_secs()) {
378 let _ = response_tx.send(ScheduleRegistrationResult::InvalidDuration { reason });
379 return;
380 }
381 let labeled_message = match validate_scheduled_message(&message) {
382 Ok(message) => message,
383 Err(reason) => {
384 let _ = response_tx.send(ScheduleRegistrationResult::InvalidMessage { reason });
385 return;
386 }
387 };
388
389 let task_id = id.unwrap_or_else(next_task_id);
390 if self.tasks.contains_key(&task_id) {
391 let _ = response_tx.send(ScheduleRegistrationResult::InvalidMessage {
392 reason: format!("task ID {task_id} is already active"),
393 });
394 return;
395 }
396 let delivery_identity = next_delivery_identity(&task_id);
397 let now = Instant::now();
398 let sq_tx = self.sq_tx.clone();
399 let session_generation = self.session_generation;
400 let accepted_tx = self.accepted_tx.clone();
401 let task_id_for_fire = task_id.clone();
402 let message_for_fire = labeled_message.clone();
403
404 let handle = tokio::spawn(async move {
405 let mut fire_sequence = 1_u64;
406 tokio::time::sleep(interval).await;
407 loop {
408 let submission = scheduled_submission(
409 &delivery_identity,
410 fire_sequence,
411 session_generation,
412 message_for_fire.clone(),
413 );
414 deliver_until_accepted(&sq_tx, submission).await;
415
416 let next_fire_at = Instant::now() + interval;
417 if accepted_tx
418 .send(AcceptedFire {
419 task_id: task_id_for_fire.clone(),
420 next_fire_at: Some(next_fire_at),
421 })
422 .is_err()
423 {
424 break;
425 }
426 let Some(next_sequence) = fire_sequence.checked_add(1) else {
427 tracing::warn!(
428 task_id = %task_id_for_fire,
429 "recurring scheduler exhausted its fire sequence"
430 );
431 break;
432 };
433 fire_sequence = next_sequence;
434 tokio::time::sleep(interval).await;
435 }
436 });
437
438 self.tasks.insert(
439 task_id.clone(),
440 ActiveTask {
441 info: ScheduledTaskInfo {
442 id: task_id.clone(),
443 message: labeled_message,
444 kind: ScheduleKind::Recurring { interval },
445 created_at: now,
446 fire_at: now + interval,
447 },
448 handle,
449 },
450 );
451 let _ = response_tx.send(ScheduleRegistrationResult::Registered { task_id });
452 }
453
454 fn handle_cancel(&mut self, id: String, response_tx: oneshot::Sender<CancelResult>) {
455 if let Some(task) = self.tasks.remove(&id) {
456 task.handle.abort();
457 let _ = response_tx.send(CancelResult::Cancelled);
458 } else {
459 let _ = response_tx.send(CancelResult::NotFound);
460 }
461 }
462
463 fn handle_list(&self, response_tx: oneshot::Sender<Vec<ScheduledTaskInfo>>) {
464 let snapshot = self.tasks.values().map(|task| task.info.clone()).collect();
465 let _ = response_tx.send(snapshot);
466 }
467}
468
469fn scheduled_submission(
470 delivery_identity: &str,
471 fire_sequence: u64,
472 session_generation: u64,
473 text: String,
474) -> StructuredSubmission {
475 let submission_id = format!("{delivery_identity}:fire:{fire_sequence}");
476 StructuredSubmission {
477 id: submission_id.clone(),
478 source: SubmissionSource::Scheduler,
479 sender_generation: session_generation,
480 items: vec![SubmissionItem {
481 id: format!("{submission_id}:item:1"),
482 enqueue_sequence: fire_sequence,
483 kind: SubmissionKind::UserTurn,
484 text,
485 attachments: Vec::new(),
486 }],
487 }
488}
489
490async fn deliver_until_accepted(sq_tx: &mpsc::Sender<SessionOp>, submission: StructuredSubmission) {
491 let (receipt_tx, mut receipt_rx) = mpsc::unbounded_channel();
492 let mut submit_required = true;
493 let mut attempts = 0_u64;
494
495 loop {
496 attempts = attempts.saturating_add(1);
497 let operation = if submit_required {
498 SessionOp::SubmitStructuredTracked {
499 submission: submission.clone(),
500 receipt_tx: Some(receipt_tx.clone()),
501 }
502 } else {
503 SessionOp::ReconcileStructuredTracked {
504 submission: submission.clone(),
505 receipt_tx: Some(receipt_tx.clone()),
506 }
507 };
508
509 match tokio::time::timeout(DELIVERY_SEND_TIMEOUT, sq_tx.send(operation)).await {
510 Ok(Ok(())) => {}
511 Ok(Err(_)) | Err(_) => {
512 log_blocked_delivery(&submission.id, attempts, "session queue unavailable");
513 tokio::time::sleep(DELIVERY_RETRY_DELAY).await;
514 continue;
515 }
516 }
517
518 let receipt = tokio::time::timeout(
519 DELIVERY_RECEIPT_TIMEOUT,
520 next_matching_receipt(&mut receipt_rx, &submission.id),
521 )
522 .await;
523 match receipt {
524 Ok(Some(receipt)) if receipt.disposition.has_durable_custody() => return,
525 Ok(Some(receipt)) => {
526 submit_required = match receipt.disposition {
527 SubmissionReceiptDisposition::NotAccepted => true,
528 SubmissionReceiptDisposition::Rejected { reason } => {
529 if attempts == 1 || attempts.is_power_of_two() {
530 tracing::warn!(
531 submission_id = %submission.id,
532 ?reason,
533 attempts,
534 "scheduled fire remains blocked before durable custody"
535 );
536 }
537 true
538 }
539 SubmissionReceiptDisposition::AcceptedPending
540 | SubmissionReceiptDisposition::AlreadyAccepted { .. } => false,
541 };
542 }
543 Ok(None) | Err(_) => {
544 submit_required = false;
547 log_blocked_delivery(&submission.id, attempts, "durable receipt unavailable");
548 }
549 }
550 tokio::time::sleep(DELIVERY_RETRY_DELAY).await;
551 }
552}
553
554async fn next_matching_receipt(
555 receipt_rx: &mut mpsc::UnboundedReceiver<SubmissionReceipt>,
556 submission_id: &str,
557) -> Option<SubmissionReceipt> {
558 while let Some(receipt) = receipt_rx.recv().await {
559 if receipt.submission_id == submission_id && receipt.source == SubmissionSource::Scheduler {
560 return Some(receipt);
561 }
562 }
563 None
564}
565
566fn log_blocked_delivery(submission_id: &str, attempts: u64, reason: &str) {
567 if attempts == 1 || attempts.is_power_of_two() {
568 tracing::warn!(
569 submission_id,
570 attempts,
571 reason,
572 "scheduled fire retained for bounded retry"
573 );
574 }
575}
576
577#[allow(dead_code)]
578pub(crate) fn spawn_scheduler_actor(
579 sq_tx: mpsc::Sender<SessionOp>,
580 session_generation: u64,
581 cancel_token: CancellationToken,
582) -> (SchedulerHandle, JoinHandle<()>) {
583 let (cmd_tx, cmd_rx) = mpsc::channel(SCHEDULER_COMMAND_CAPACITY);
584 let handle = SchedulerHandle::new(cmd_tx);
585 let actor = SchedulerActor::new(cmd_rx, sq_tx, session_generation, cancel_token);
586 let join = tokio::spawn(async move { actor.run().await });
587 (handle, join)
588}
589
590pub fn create_delay_tool_and_scheduler() -> (Arc<dyn AgentTool>, PendingSchedulerActor) {
591 let (cmd_tx, cmd_rx) = mpsc::channel(SCHEDULER_COMMAND_CAPACITY);
592 let handle = SchedulerHandle::new(cmd_tx);
593 let tool: Arc<dyn AgentTool> = Arc::new(DelayTool::new(handle));
594 (tool, PendingSchedulerActor { cmd_rx })
595}
596
597pub fn create_scheduler_tools() -> (Vec<Arc<dyn AgentTool>>, PendingSchedulerActor) {
598 let (cmd_tx, cmd_rx) = mpsc::channel(SCHEDULER_COMMAND_CAPACITY);
599 let handle = SchedulerHandle::new(cmd_tx);
600 let tools: Vec<Arc<dyn AgentTool>> = vec![
601 Arc::new(DelayTool::new(handle.clone())),
602 Arc::new(ScheduleTool::new(handle.clone())),
603 Arc::new(ListScheduledTasksTool::new(handle.clone())),
604 Arc::new(CancelScheduledTaskTool::new(handle)),
605 ];
606 (tools, PendingSchedulerActor { cmd_rx })
607}
608
609pub struct PendingSchedulerActor {
610 cmd_rx: mpsc::Receiver<ScheduleCommand>,
611}
612
613impl PendingSchedulerActor {
614 pub fn spawn(
615 self,
616 sq_tx: mpsc::Sender<SessionOp>,
617 session_generation: u64,
618 cancel_token: CancellationToken,
619 ) -> JoinHandle<()> {
620 let actor = SchedulerActor::new(self.cmd_rx, sq_tx, session_generation, cancel_token);
621 tokio::spawn(async move { actor.run().await })
622 }
623}
624
625impl fmt::Debug for PendingSchedulerActor {
626 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
627 f.debug_struct("PendingSchedulerActor")
628 .field("cmd_rx", &"mpsc::Receiver<ScheduleCommand>")
629 .finish()
630 }
631}
632
633pub(crate) struct DelayTool {
634 handle: SchedulerHandle,
635}
636
637impl DelayTool {
638 pub(crate) fn new(handle: SchedulerHandle) -> Self {
639 Self { handle }
640 }
641}
642
643#[async_trait]
644impl AgentTool for DelayTool {
645 fn name(&self) -> &str {
646 "delay"
647 }
648
649 fn description(&self) -> &str {
650 "Schedule a one-shot delayed follow-up message. Delivery transfers only after the Session Actor durably accepts the exact scheduled fire. Session-scoped; minimum 1 second, maximum 86400 seconds."
651 }
652
653 fn parameters(&self) -> serde_json::Value {
654 serde_json::json!({
655 "type": "object",
656 "properties": {
657 "message": {"type": "string", "description": "Follow-up message."},
658 "delay_secs": {
659 "type": "integer",
660 "minimum": MIN_DELAY_SECS,
661 "maximum": MAX_DELAY_SECS
662 }
663 },
664 "required": ["message", "delay_secs"]
665 })
666 }
667
668 async fn execute(&self, input: serde_json::Value) -> ToolResult {
669 let message = match input.get("message").and_then(|value| value.as_str()) {
670 Some(message) if !message.is_empty() => message.to_owned(),
671 _ => return ToolResult::error("missing or empty 'message' field"),
672 };
673 let delay_secs = match input.get("delay_secs").and_then(|value| value.as_u64()) {
674 Some(delay_secs) => delay_secs,
675 None => {
676 return ToolResult::error(
677 "missing or invalid 'delay_secs' field (expected a positive integer)",
678 );
679 }
680 };
681 if let Err(reason) = validate_delay_secs(delay_secs) {
682 return ToolResult::error(reason);
683 }
684
685 let (response_tx, response_rx) = oneshot::channel();
686 if let Err(error) = self
687 .handle
688 .send(ScheduleCommand::RegisterOneShot {
689 id: None,
690 message,
691 delay: Duration::from_secs(delay_secs),
692 response_tx,
693 })
694 .await
695 {
696 return scheduler_send_error(error);
697 }
698 registration_result(response_rx.await, format!("Delay: {delay_secs} second(s)"))
699 }
700
701 fn nature(&self) -> ToolNature {
702 ToolNature::Execute
703 }
704
705 fn family(&self) -> ToolFamily {
706 ToolFamily::Extension
707 }
708}
709
710pub(crate) struct ScheduleTool {
711 handle: SchedulerHandle,
712}
713
714impl ScheduleTool {
715 pub(crate) fn new(handle: SchedulerHandle) -> Self {
716 Self { handle }
717 }
718}
719
720#[async_trait]
721impl AgentTool for ScheduleTool {
722 fn name(&self) -> &str {
723 "schedule"
724 }
725
726 fn description(&self) -> &str {
727 "Schedule a recurring follow-up. Each fire uses a stable structured identity and the next interval does not begin until Actor durable acceptance. Session-scoped; interval 5 to 3600 seconds."
728 }
729
730 fn parameters(&self) -> serde_json::Value {
731 serde_json::json!({
732 "type": "object",
733 "properties": {
734 "message": {"type": "string", "description": "Recurring follow-up message."},
735 "interval_secs": {
736 "type": "integer",
737 "minimum": MIN_INTERVAL_SECS,
738 "maximum": MAX_INTERVAL_SECS
739 }
740 },
741 "required": ["message", "interval_secs"]
742 })
743 }
744
745 async fn execute(&self, input: serde_json::Value) -> ToolResult {
746 let message = match input.get("message").and_then(|value| value.as_str()) {
747 Some(message) if !message.is_empty() => message.to_owned(),
748 _ => return ToolResult::error("missing or empty 'message' field"),
749 };
750 let interval_secs = match input.get("interval_secs").and_then(|value| value.as_u64()) {
751 Some(interval_secs) => interval_secs,
752 None => {
753 return ToolResult::error(
754 "missing or invalid 'interval_secs' field (expected a positive integer)",
755 );
756 }
757 };
758 if let Err(reason) = validate_interval_secs(interval_secs) {
759 return ToolResult::error(reason);
760 }
761
762 let (response_tx, response_rx) = oneshot::channel();
763 if let Err(error) = self
764 .handle
765 .send(ScheduleCommand::RegisterRecurring {
766 id: None,
767 message,
768 interval: Duration::from_secs(interval_secs),
769 response_tx,
770 })
771 .await
772 {
773 return scheduler_send_error(error);
774 }
775 registration_result(
776 response_rx.await,
777 format!("Interval: {interval_secs} second(s)"),
778 )
779 }
780
781 fn nature(&self) -> ToolNature {
782 ToolNature::Execute
783 }
784
785 fn family(&self) -> ToolFamily {
786 ToolFamily::Extension
787 }
788}
789
790pub(crate) struct ListScheduledTasksTool {
791 handle: SchedulerHandle,
792}
793
794impl ListScheduledTasksTool {
795 pub(crate) fn new(handle: SchedulerHandle) -> Self {
796 Self { handle }
797 }
798}
799
800#[async_trait]
801impl AgentTool for ListScheduledTasksTool {
802 fn name(&self) -> &str {
803 "list_scheduled_tasks"
804 }
805
806 fn description(&self) -> &str {
807 "List active scheduled tasks, including whether a due fire is blocked awaiting durable Actor custody."
808 }
809
810 fn parameters(&self) -> serde_json::Value {
811 serde_json::json!({"type": "object", "properties": {}})
812 }
813
814 async fn execute(&self, _input: serde_json::Value) -> ToolResult {
815 let (response_tx, response_rx) = oneshot::channel();
816 if let Err(error) = self
817 .handle
818 .send(ScheduleCommand::List { response_tx })
819 .await
820 {
821 return scheduler_send_error(error);
822 }
823 match response_rx.await {
824 Ok(tasks) if tasks.is_empty() => ToolResult::success("No active scheduled tasks."),
825 Ok(tasks) => {
826 const MAX_DISPLAY: usize = 20;
827 let total = tasks.len();
828 let mut text = format!("{total} active task(s):\n");
829 for info in tasks.iter().take(MAX_DISPLAY) {
830 text.push_str(&format!(
831 " {} | {} | next: {}s | {}\n",
832 info.id,
833 info.kind,
834 info.remaining().as_secs(),
835 info.delivery_state(),
836 ));
837 }
838 let omitted = total.saturating_sub(MAX_DISPLAY);
839 if omitted > 0 {
840 text.push_str(&format!("... and {omitted} more task(s) not shown\n"));
841 }
842 ToolResult::success(text.trim_end().to_owned())
843 }
844 Err(_) => ToolResult::error("scheduler dropped the request"),
845 }
846 }
847
848 fn nature(&self) -> ToolNature {
849 ToolNature::Read
850 }
851
852 fn family(&self) -> ToolFamily {
853 ToolFamily::Extension
854 }
855}
856
857pub(crate) struct CancelScheduledTaskTool {
858 handle: SchedulerHandle,
859}
860
861impl CancelScheduledTaskTool {
862 pub(crate) fn new(handle: SchedulerHandle) -> Self {
863 Self { handle }
864 }
865}
866
867#[async_trait]
868impl AgentTool for CancelScheduledTaskTool {
869 fn name(&self) -> &str {
870 "cancel_scheduled_task"
871 }
872
873 fn description(&self) -> &str {
874 "Cancel an active scheduled follow-up by task ID."
875 }
876
877 fn parameters(&self) -> serde_json::Value {
878 serde_json::json!({
879 "type": "object",
880 "properties": {"task_id": {"type": "string"}},
881 "required": ["task_id"]
882 })
883 }
884
885 async fn execute(&self, input: serde_json::Value) -> ToolResult {
886 let task_id = match input.get("task_id").and_then(|value| value.as_str()) {
887 Some(task_id) if !task_id.is_empty() => task_id.to_owned(),
888 _ => return ToolResult::error("missing or empty 'task_id' field"),
889 };
890 let (response_tx, response_rx) = oneshot::channel();
891 if let Err(error) = self
892 .handle
893 .send(ScheduleCommand::Cancel {
894 id: task_id.clone(),
895 response_tx,
896 })
897 .await
898 {
899 return scheduler_send_error(error);
900 }
901 match response_rx.await {
902 Ok(CancelResult::Cancelled) => {
903 ToolResult::success(format!("Task {task_id} cancelled."))
904 }
905 Ok(CancelResult::NotFound) => {
906 ToolResult::success(format!("Task {task_id} not found or already completed."))
907 }
908 Err(_) => ToolResult::error("scheduler dropped the request"),
909 }
910 }
911
912 fn nature(&self) -> ToolNature {
913 ToolNature::Execute
914 }
915
916 fn family(&self) -> ToolFamily {
917 ToolFamily::Extension
918 }
919
920 fn summary_fields(&self) -> &'static [&'static str] {
921 &["task_id"]
922 }
923}
924
925fn scheduler_send_error(error: SchedulerSendError) -> ToolResult {
926 match error {
927 SchedulerSendError::Full => ToolResult::error("scheduler is busy; try again"),
928 SchedulerSendError::Closed => ToolResult::error("scheduler is not available"),
929 }
930}
931
932fn registration_result(
933 result: Result<ScheduleRegistrationResult, oneshot::error::RecvError>,
934 timing: String,
935) -> ToolResult {
936 match result {
937 Ok(ScheduleRegistrationResult::Registered { task_id }) => ToolResult::success(format!(
938 "Scheduled follow-up registered.\nTask ID: {task_id}\n{timing}"
939 )),
940 Ok(ScheduleRegistrationResult::InvalidDuration { reason })
941 | Ok(ScheduleRegistrationResult::InvalidMessage { reason }) => ToolResult::error(reason),
942 Err(_) => ToolResult::error("scheduler dropped the request"),
943 }
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949 use talos_core::session::{PendingSubmissionState, SubmissionRejectionReason};
950
951 async fn yield_times(times: usize) {
952 for _ in 0..times {
953 tokio::task::yield_now().await;
954 }
955 }
956
957 async fn advance_delivery_retry() {
958 yield_times(10).await;
962 tokio::time::advance(DELIVERY_RECEIPT_TIMEOUT).await;
963 yield_times(10).await;
964 tokio::time::advance(DELIVERY_RETRY_DELAY).await;
965 yield_times(20).await;
966 }
967
968 async fn register_one_shot(handle: &SchedulerHandle, id: &str, delay: Duration) {
969 let (response_tx, response_rx) = oneshot::channel();
970 handle
971 .send(ScheduleCommand::RegisterOneShot {
972 id: Some(id.to_owned()),
973 message: "check the build".into(),
974 delay,
975 response_tx,
976 })
977 .await
978 .expect("operation should succeed");
979 assert!(matches!(
980 response_rx.await.expect("operation should succeed"),
981 ScheduleRegistrationResult::Registered { .. }
982 ));
983 }
984
985 fn split_tracked_operation(
986 operation: SessionOp,
987 ) -> (
988 bool,
989 StructuredSubmission,
990 mpsc::UnboundedSender<SubmissionReceipt>,
991 ) {
992 match operation {
993 SessionOp::SubmitStructuredTracked {
994 submission,
995 receipt_tx: Some(receipt_tx),
996 } => (true, submission, receipt_tx),
997 SessionOp::ReconcileStructuredTracked {
998 submission,
999 receipt_tx: Some(receipt_tx),
1000 } => (false, submission, receipt_tx),
1001 other => panic!("expected tracked structured operation, got {other:?}"),
1002 }
1003 }
1004
1005 fn accept(
1006 submission: &StructuredSubmission,
1007 receipt_tx: &mpsc::UnboundedSender<SubmissionReceipt>,
1008 disposition: SubmissionReceiptDisposition,
1009 ) {
1010 receipt_tx
1011 .send(SubmissionReceipt {
1012 session_id: "session-test".into(),
1013 session_generation: submission.sender_generation,
1014 submission_id: submission.id.clone(),
1015 reservation_id: submission.id.clone(),
1016 receipt_id: "receipt-test".into(),
1017 source: submission.source,
1018 item_count: submission.items.len(),
1019 total_text_bytes: submission.total_text_bytes(),
1020 disposition,
1021 })
1022 .expect("operation should succeed");
1023 }
1024
1025 async fn list(handle: &SchedulerHandle) -> Vec<ScheduledTaskInfo> {
1026 let (response_tx, response_rx) = oneshot::channel();
1027 handle
1028 .send(ScheduleCommand::List { response_tx })
1029 .await
1030 .expect("operation should succeed");
1031 response_rx.await.expect("operation should succeed")
1032 }
1033
1034 #[test]
1035 fn validates_bounds_and_label() {
1036 assert!(validate_delay_secs(MIN_DELAY_SECS).is_ok());
1037 assert!(validate_delay_secs(0).is_err());
1038 assert!(validate_interval_secs(MIN_INTERVAL_SECS).is_ok());
1039 assert!(validate_interval_secs(MIN_INTERVAL_SECS - 1).is_err());
1040 assert_eq!(
1041 label_scheduled_message("check"),
1042 "[scheduled-followup] check"
1043 );
1044 }
1045
1046 #[tokio::test(start_paused = true)]
1047 async fn scheduled_fire_uses_the_bound_actor_generation() {
1048 let (sq_tx, mut sq_rx) = mpsc::channel(8);
1049 let (handle, _join) = spawn_scheduler_actor(sq_tx, 7, CancellationToken::new());
1050 register_one_shot(&handle, "generation-bound", Duration::from_secs(1)).await;
1051
1052 tokio::time::advance(Duration::from_secs(2)).await;
1053 yield_times(10).await;
1054 let (is_submit, submission, receipt_tx) =
1055 split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1056 assert!(is_submit);
1057 assert_eq!(submission.source, SubmissionSource::Scheduler);
1058 assert_eq!(submission.sender_generation, 7);
1059
1060 accept(
1061 &submission,
1062 &receipt_tx,
1063 SubmissionReceiptDisposition::AcceptedPending,
1064 );
1065 yield_times(10).await;
1066 assert!(list(&handle).await.is_empty());
1067 }
1068
1069 #[tokio::test(start_paused = true)]
1070 async fn one_shot_transfers_only_after_durable_receipt() {
1071 let (sq_tx, mut sq_rx) = mpsc::channel(8);
1072 let (handle, _join) = spawn_scheduler_actor(sq_tx, 0, CancellationToken::new());
1073 register_one_shot(&handle, "one", Duration::from_secs(1)).await;
1074
1075 tokio::time::advance(Duration::from_secs(2)).await;
1076 yield_times(10).await;
1077 let (is_submit, submission, receipt_tx) =
1078 split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1079 assert!(is_submit);
1080 assert_eq!(submission.source, SubmissionSource::Scheduler);
1081 assert!(
1082 submission.items[0]
1083 .text
1084 .starts_with(SCHEDULED_FOLLOWUP_LABEL)
1085 );
1086 assert_eq!(list(&handle).await.len(), 1);
1087
1088 accept(
1089 &submission,
1090 &receipt_tx,
1091 SubmissionReceiptDisposition::AcceptedPending,
1092 );
1093 yield_times(10).await;
1094 assert!(list(&handle).await.is_empty());
1095 }
1096
1097 #[tokio::test(start_paused = true)]
1098 async fn lost_ack_reconciles_the_exact_fire_identity() {
1099 let (sq_tx, mut sq_rx) = mpsc::channel(8);
1100 let (handle, _join) = spawn_scheduler_actor(sq_tx, 0, CancellationToken::new());
1101 register_one_shot(&handle, "lost-ack", Duration::from_secs(1)).await;
1102
1103 tokio::time::advance(Duration::from_secs(2)).await;
1104 yield_times(10).await;
1105 let (_, submitted, _lost_receipt_tx) =
1106 split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1107
1108 advance_delivery_retry().await;
1109 let (is_submit, reconciled, receipt_tx) =
1110 split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1111 assert!(!is_submit);
1112 assert_eq!(reconciled, submitted);
1113
1114 accept(
1115 &reconciled,
1116 &receipt_tx,
1117 SubmissionReceiptDisposition::AlreadyAccepted {
1118 state: PendingSubmissionState::Running,
1119 turn_id: Some("turn-1".into()),
1120 },
1121 );
1122 yield_times(10).await;
1123 assert!(list(&handle).await.is_empty());
1124 }
1125
1126 #[tokio::test(start_paused = true)]
1127 async fn closed_session_queue_retains_a_blocked_fire() {
1128 let (sq_tx, sq_rx) = mpsc::channel(1);
1129 let (handle, _join) = spawn_scheduler_actor(sq_tx, 0, CancellationToken::new());
1130 drop(sq_rx);
1131 register_one_shot(&handle, "closed", Duration::from_secs(1)).await;
1132
1133 tokio::time::advance(Duration::from_secs(5)).await;
1134 yield_times(20).await;
1135 let tasks = list(&handle).await;
1136 assert_eq!(tasks.len(), 1);
1137 assert_eq!(tasks[0].delivery_state(), "delivery-blocked");
1138 }
1139
1140 #[tokio::test(start_paused = true)]
1141 async fn rejection_retries_without_changing_identity() {
1142 let (sq_tx, mut sq_rx) = mpsc::channel(8);
1143 let (handle, _join) = spawn_scheduler_actor(sq_tx, 0, CancellationToken::new());
1144 register_one_shot(&handle, "rejected", Duration::from_secs(1)).await;
1145
1146 tokio::time::advance(Duration::from_secs(2)).await;
1147 yield_times(10).await;
1148 let (_, first, receipt_tx) =
1149 split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1150 accept(
1151 &first,
1152 &receipt_tx,
1153 SubmissionReceiptDisposition::Rejected {
1154 reason: SubmissionRejectionReason::LimitExceeded,
1155 },
1156 );
1157
1158 yield_times(10).await;
1159 tokio::time::advance(DELIVERY_RETRY_DELAY).await;
1160 yield_times(20).await;
1161 let (is_submit, retry, _) =
1162 split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1163 assert!(is_submit);
1164 assert_eq!(retry, first);
1165 assert_eq!(list(&handle).await.len(), 1);
1166 }
1167
1168 #[tokio::test(start_paused = true)]
1169 async fn recurring_does_not_replace_an_unresolved_fire() {
1170 let (sq_tx, mut sq_rx) = mpsc::channel(32);
1171 let (handle, _join) = spawn_scheduler_actor(sq_tx, 0, CancellationToken::new());
1172 let (response_tx, response_rx) = oneshot::channel();
1173 handle
1174 .send(ScheduleCommand::RegisterRecurring {
1175 id: Some("recurring".into()),
1176 message: "tick".into(),
1177 interval: Duration::from_secs(5),
1178 response_tx,
1179 })
1180 .await
1181 .expect("operation should succeed");
1182 assert!(response_rx.await.is_ok());
1183
1184 tokio::time::advance(Duration::from_secs(6)).await;
1185 yield_times(10).await;
1186 let (_, first, _) =
1187 split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1188
1189 advance_delivery_retry().await;
1190 let (is_submit, retry, receipt_tx) =
1191 split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1192 assert!(!is_submit);
1193 assert_eq!(
1194 retry.id, first.id,
1195 "later interval replaced unresolved fire"
1196 );
1197 assert!(sq_rx.try_recv().is_err());
1198
1199 accept(
1200 &retry,
1201 &receipt_tx,
1202 SubmissionReceiptDisposition::AcceptedPending,
1203 );
1204 yield_times(20).await;
1205
1206 tokio::time::advance(Duration::from_secs(4)).await;
1207 yield_times(10).await;
1208 assert!(sq_rx.try_recv().is_err());
1209 tokio::time::advance(Duration::from_secs(2)).await;
1210 yield_times(10).await;
1211 let (_, second, _) =
1212 split_tracked_operation(sq_rx.try_recv().expect("operation should succeed"));
1213 assert_ne!(second.id, first.id);
1214 assert!(second.id.ends_with(":fire:2"));
1215 }
1216
1217 #[tokio::test(start_paused = true)]
1218 async fn cancellation_aborts_an_unaccepted_fire() {
1219 let (sq_tx, mut sq_rx) = mpsc::channel(8);
1220 let (handle, _join) = spawn_scheduler_actor(sq_tx, 0, CancellationToken::new());
1221 register_one_shot(&handle, "cancel", Duration::from_secs(1)).await;
1222 tokio::time::advance(Duration::from_secs(2)).await;
1223 yield_times(10).await;
1224 let _ = sq_rx.try_recv().expect("operation should succeed");
1225
1226 let (response_tx, response_rx) = oneshot::channel();
1227 handle
1228 .send(ScheduleCommand::Cancel {
1229 id: "cancel".into(),
1230 response_tx,
1231 })
1232 .await
1233 .expect("operation should succeed");
1234 assert!(matches!(
1235 response_rx.await.expect("operation should succeed"),
1236 CancelResult::Cancelled
1237 ));
1238 tokio::time::advance(Duration::from_secs(10)).await;
1239 yield_times(20).await;
1240 assert!(sq_rx.try_recv().is_err());
1241 }
1242
1243 #[test]
1244 fn tool_natures_remain_permission_safe() {
1245 let (tools, _pending) = create_scheduler_tools();
1246 assert_eq!(tools[0].nature(), ToolNature::Execute);
1247 assert_eq!(tools[1].nature(), ToolNature::Execute);
1248 assert_eq!(tools[2].nature(), ToolNature::Read);
1249 assert_eq!(tools[3].nature(), ToolNature::Execute);
1250 }
1251}