solana-agent 11.0.0__py3-none-any.whl → 11.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
solana_agent/ai.py CHANGED
@@ -44,7 +44,161 @@ from abc import ABC, abstractmethod
44
44
  # DOMAIN MODELS
45
45
  #############################################
46
46
 
47
- # Define Pydantic models for structured outputs
47
+ class AgentType(str, Enum):
48
+ """Type of agent (AI or Human)."""
49
+ AI = "ai"
50
+ HUMAN = "human"
51
+
52
+
53
+ class TimeOffStatus(str, Enum):
54
+ """Status of a time off request."""
55
+ REQUESTED = "requested"
56
+ APPROVED = "approved"
57
+ REJECTED = "rejected"
58
+ CANCELLED = "cancelled"
59
+
60
+
61
+ class TimeOffRequest(BaseModel):
62
+ """Represents a request for time off from a human agent."""
63
+ request_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
64
+ agent_id: str
65
+ start_time: datetime.datetime
66
+ end_time: datetime.datetime
67
+ reason: str
68
+ status: TimeOffStatus = TimeOffStatus.REQUESTED
69
+ created_at: datetime.datetime = Field(
70
+ default_factory=lambda: datetime.datetime.now(datetime.timezone.utc))
71
+ updated_at: datetime.datetime = Field(
72
+ default_factory=lambda: datetime.datetime.now(datetime.timezone.utc))
73
+ rejection_reason: Optional[str] = None
74
+
75
+
76
+ class TimeWindow(BaseModel):
77
+ """Represents a specific time window for scheduling."""
78
+ start: datetime.datetime
79
+ end: datetime.datetime
80
+
81
+ def overlaps_with(self, other: 'TimeWindow') -> bool:
82
+ """Check if this time window overlaps with another."""
83
+ return self.start < other.end and self.end > other.start
84
+
85
+ def duration_minutes(self) -> int:
86
+ """Get the duration of the time window in minutes."""
87
+ return int((self.end - self.start).total_seconds() / 60)
88
+
89
+
90
+ class AvailabilityStatus(str, Enum):
91
+ """Status indicating an agent's availability."""
92
+ AVAILABLE = "available"
93
+ BUSY = "busy"
94
+ AWAY = "away"
95
+ DO_NOT_DISTURB = "do_not_disturb"
96
+ OFFLINE = "offline"
97
+
98
+
99
+ class RecurringSchedule(BaseModel):
100
+ """Defines a recurring schedule pattern."""
101
+ days_of_week: List[int] = Field(
102
+ [], description="Days of week (0=Monday, 6=Sunday)")
103
+ start_time: str = Field(..., description="Start time in HH:MM format")
104
+ end_time: str = Field(..., description="End time in HH:MM format")
105
+ time_zone: str = Field("UTC", description="Time zone identifier")
106
+
107
+
108
+ class AgentSchedule(BaseModel):
109
+ """Represents an agent's schedule and availability settings."""
110
+ agent_id: str
111
+ agent_type: AgentType
112
+ time_zone: str = "UTC"
113
+ working_hours: List[RecurringSchedule] = Field(default_factory=list)
114
+ focus_blocks: List[TimeWindow] = Field(default_factory=list)
115
+ availability_exceptions: List[TimeWindow] = Field(default_factory=list)
116
+ availability_status: AvailabilityStatus = AvailabilityStatus.AVAILABLE
117
+ task_switching_penalty: int = Field(
118
+ 5, description="Minutes of overhead when switching contexts")
119
+ specialization_efficiency: Dict[str, float] = Field(
120
+ default_factory=dict,
121
+ description="Efficiency multiplier for different task types (1.0 = standard)"
122
+ )
123
+ updated_at: datetime.datetime = Field(
124
+ default_factory=lambda: datetime.datetime.now(datetime.timezone.utc))
125
+
126
+ def is_available_at(self, time_point: datetime.datetime) -> bool:
127
+ """Check if the agent is available at a specific time point."""
128
+ # Convert time_point to agent's timezone
129
+ local_time = time_point.astimezone(datetime.timezone.utc)
130
+
131
+ # Check if it falls within any exceptions (unavailable times)
132
+ for exception in self.availability_exceptions:
133
+ if exception.start <= local_time <= exception.end:
134
+ return False # Not available during exceptions
135
+
136
+ # Check if it's within working hours
137
+ weekday = local_time.weekday() # 0-6, Monday is 0
138
+ local_time_str = local_time.strftime("%H:%M")
139
+
140
+ for schedule in self.working_hours:
141
+ if weekday in schedule.days_of_week:
142
+ if schedule.start_time <= local_time_str <= schedule.end_time:
143
+ return True # Available during working hours
144
+
145
+ # Not within working hours
146
+ return False
147
+
148
+
149
+ class ScheduleConstraint(str, Enum):
150
+ """Types of schedule constraints."""
151
+ MUST_START_AFTER = "must_start_after"
152
+ MUST_END_BEFORE = "must_end_before"
153
+ FIXED_TIME = "fixed_time"
154
+ DEPENDENCY = "dependency"
155
+ SAME_AGENT = "same_agent"
156
+ DIFFERENT_AGENT = "different_agent"
157
+ SEQUENTIAL = "sequential"
158
+
159
+
160
+ class ScheduledTask(BaseModel):
161
+ """A task with scheduling information."""
162
+ task_id: str
163
+ parent_id: Optional[str] = None
164
+ title: str
165
+ description: str
166
+ estimated_minutes: int
167
+ priority: int = 5 # 1-10 scale
168
+ assigned_to: Optional[str] = None
169
+ scheduled_start: Optional[datetime.datetime] = None
170
+ scheduled_end: Optional[datetime.datetime] = None
171
+ actual_start: Optional[datetime.datetime] = None
172
+ actual_end: Optional[datetime.datetime] = None
173
+ status: str = "pending"
174
+ dependencies: List[str] = Field(default_factory=list)
175
+ constraints: List[Dict[str, Any]] = Field(default_factory=list)
176
+ specialization_tags: List[str] = Field(default_factory=list)
177
+ cognitive_load: int = 5 # 1-10 scale
178
+ created_at: datetime.datetime = Field(
179
+ default_factory=lambda: datetime.datetime.now(datetime.timezone.utc))
180
+ updated_at: datetime.datetime = Field(
181
+ default_factory=lambda: datetime.datetime.now(datetime.timezone.utc))
182
+
183
+ def get_time_window(self) -> Optional[TimeWindow]:
184
+ """Get the scheduled time window if both start and end are set."""
185
+ if self.scheduled_start and self.scheduled_end:
186
+ return TimeWindow(start=self.scheduled_start, end=self.scheduled_end)
187
+ return None
188
+
189
+
190
+ class SchedulingEvent(BaseModel):
191
+ """Represents a scheduling-related event."""
192
+ event_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
193
+ # "task_scheduled", "task_completed", "constraint_violation", etc.
194
+ event_type: str
195
+ task_id: Optional[str] = None
196
+ agent_id: Optional[str] = None
197
+ timestamp: datetime.datetime = Field(
198
+ default_factory=lambda: datetime.datetime.now(datetime.timezone.utc))
199
+ details: Dict[str, Any] = Field(default_factory=dict)
200
+
201
+
48
202
  class MemoryInsightModel(BaseModel):
49
203
  fact: str = Field(...,
50
204
  description="The factual information worth remembering")
@@ -1537,6 +1691,233 @@ class DualMemoryProvider(MemoryProvider):
1537
1691
  await self.zep_provider.delete(user_id)
1538
1692
 
1539
1693
 
1694
+ class SchedulingRepository:
1695
+ """Repository for managing scheduled tasks and agent schedules."""
1696
+
1697
+ def __init__(self, db_provider: DataStorageProvider):
1698
+ self.db = db_provider
1699
+ self.scheduled_tasks_collection = "scheduled_tasks"
1700
+ self.agent_schedules_collection = "agent_schedules"
1701
+ self.scheduling_events_collection = "scheduling_events"
1702
+
1703
+ # Ensure collections exist
1704
+ self.db.create_collection(self.scheduled_tasks_collection)
1705
+ self.db.create_collection(self.agent_schedules_collection)
1706
+ self.db.create_collection(self.scheduling_events_collection)
1707
+
1708
+ # Create indexes
1709
+ self.db.create_index(self.scheduled_tasks_collection, [("task_id", 1)])
1710
+ self.db.create_index(self.scheduled_tasks_collection, [
1711
+ ("assigned_to", 1)])
1712
+ self.db.create_index(self.scheduled_tasks_collection, [
1713
+ ("scheduled_start", 1)])
1714
+ self.db.create_index(self.scheduled_tasks_collection, [("status", 1)])
1715
+
1716
+ self.db.create_index(
1717
+ self.agent_schedules_collection, [("agent_id", 1)])
1718
+ self.db.create_index(
1719
+ self.scheduling_events_collection, [("timestamp", 1)])
1720
+
1721
+ # Task CRUD operations
1722
+ def create_scheduled_task(self, task: ScheduledTask) -> str:
1723
+ """Create a new scheduled task."""
1724
+ task_dict = task.model_dump(mode="json")
1725
+ return self.db.insert_one(self.scheduled_tasks_collection, task_dict)
1726
+
1727
+ def get_scheduled_task(self, task_id: str) -> Optional[ScheduledTask]:
1728
+ """Get a scheduled task by ID."""
1729
+ data = self.db.find_one(
1730
+ self.scheduled_tasks_collection, {"task_id": task_id})
1731
+ return ScheduledTask(**data) if data else None
1732
+
1733
+ def update_scheduled_task(self, task: ScheduledTask) -> bool:
1734
+ """Update a scheduled task."""
1735
+ task_dict = task.model_dump(mode="json")
1736
+ task_dict["updated_at"] = datetime.datetime.now(datetime.timezone.utc)
1737
+ return self.db.update_one(
1738
+ self.scheduled_tasks_collection,
1739
+ {"task_id": task.task_id},
1740
+ {"$set": task_dict}
1741
+ )
1742
+
1743
+ def get_agent_tasks(
1744
+ self,
1745
+ agent_id: str,
1746
+ start_time: Optional[datetime.datetime] = None,
1747
+ end_time: Optional[datetime.datetime] = None,
1748
+ status: Optional[str] = None
1749
+ ) -> List[ScheduledTask]:
1750
+ """Get all scheduled tasks for an agent within a time range."""
1751
+ query = {"assigned_to": agent_id}
1752
+
1753
+ if start_time or end_time:
1754
+ time_query = {}
1755
+ if start_time:
1756
+ time_query["$gte"] = start_time
1757
+ if end_time:
1758
+ time_query["$lte"] = end_time
1759
+ query["scheduled_start"] = time_query
1760
+
1761
+ if status:
1762
+ query["status"] = status
1763
+
1764
+ data = self.db.find(self.scheduled_tasks_collection, query)
1765
+ return [ScheduledTask(**item) for item in data]
1766
+
1767
+ def get_tasks_by_status(self, status: str) -> List[ScheduledTask]:
1768
+ """Get all tasks with a specific status."""
1769
+ data = self.db.find(
1770
+ self.scheduled_tasks_collection, {"status": status})
1771
+ return [ScheduledTask(**item) for item in data]
1772
+
1773
+ def get_unscheduled_tasks(self) -> List[ScheduledTask]:
1774
+ """Get all tasks that haven't been scheduled yet."""
1775
+ query = {
1776
+ "scheduled_start": None,
1777
+ "status": {"$in": ["pending", "ready"]}
1778
+ }
1779
+ data = self.db.find(self.scheduled_tasks_collection, query)
1780
+ return [ScheduledTask(**item) for item in data]
1781
+
1782
+ # Agent schedule operations
1783
+ def save_agent_schedule(self, schedule: AgentSchedule) -> bool:
1784
+ """Create or update an agent's schedule."""
1785
+ schedule_dict = schedule.model_dump(mode="json")
1786
+ schedule_dict["updated_at"] = datetime.datetime.now(
1787
+ datetime.timezone.utc)
1788
+ return self.db.update_one(
1789
+ self.agent_schedules_collection,
1790
+ {"agent_id": schedule.agent_id},
1791
+ {"$set": schedule_dict},
1792
+ upsert=True
1793
+ )
1794
+
1795
+ def get_agent_schedule(self, agent_id: str) -> Optional[AgentSchedule]:
1796
+ """Get an agent's schedule by ID."""
1797
+ data = self.db.find_one(self.agent_schedules_collection, {
1798
+ "agent_id": agent_id})
1799
+ return AgentSchedule(**data) if data else None
1800
+
1801
+ def get_all_agent_schedules(self) -> List[AgentSchedule]:
1802
+ """Get schedules for all agents."""
1803
+ data = self.db.find(self.agent_schedules_collection, {})
1804
+ return [AgentSchedule(**item) for item in data]
1805
+
1806
+ # Event logging
1807
+ def log_scheduling_event(self, event: SchedulingEvent) -> str:
1808
+ """Log a scheduling-related event."""
1809
+ event_dict = event.model_dump(mode="json")
1810
+ return self.db.insert_one(self.scheduling_events_collection, event_dict)
1811
+
1812
+ def get_scheduling_events(
1813
+ self,
1814
+ start_time: Optional[datetime.datetime] = None,
1815
+ end_time: Optional[datetime.datetime] = None,
1816
+ event_type: Optional[str] = None,
1817
+ task_id: Optional[str] = None,
1818
+ agent_id: Optional[str] = None,
1819
+ limit: int = 100
1820
+ ) -> List[SchedulingEvent]:
1821
+ """Get scheduling events with optional filters."""
1822
+ query = {}
1823
+
1824
+ if start_time or end_time:
1825
+ time_query = {}
1826
+ if start_time:
1827
+ time_query["$gte"] = start_time
1828
+ if end_time:
1829
+ time_query["$lte"] = end_time
1830
+ query["timestamp"] = time_query
1831
+
1832
+ if event_type:
1833
+ query["event_type"] = event_type
1834
+
1835
+ if task_id:
1836
+ query["task_id"] = task_id
1837
+
1838
+ if agent_id:
1839
+ query["agent_id"] = agent_id
1840
+
1841
+ data = self.db.find(
1842
+ self.scheduling_events_collection,
1843
+ query,
1844
+ sort=[("timestamp", -1)],
1845
+ limit=limit
1846
+ )
1847
+
1848
+ return [SchedulingEvent(**item) for item in data]
1849
+
1850
+ def create_time_off_request(self, request: TimeOffRequest) -> str:
1851
+ """Create a new time-off request."""
1852
+ request_dict = request.model_dump(mode="json")
1853
+ return self.db.insert_one("time_off_requests", request_dict)
1854
+
1855
+ def get_time_off_request(self, request_id: str) -> Optional[TimeOffRequest]:
1856
+ """Get a time-off request by ID."""
1857
+ data = self.db.find_one("time_off_requests", {
1858
+ "request_id": request_id})
1859
+ return TimeOffRequest(**data) if data else None
1860
+
1861
+ def update_time_off_request(self, request: TimeOffRequest) -> bool:
1862
+ """Update a time-off request."""
1863
+ request_dict = request.model_dump(mode="json")
1864
+ request_dict["updated_at"] = datetime.datetime.now(
1865
+ datetime.timezone.utc)
1866
+ return self.db.update_one(
1867
+ "time_off_requests",
1868
+ {"request_id": request.request_id},
1869
+ {"$set": request_dict}
1870
+ )
1871
+
1872
+ def get_agent_time_off_requests(
1873
+ self,
1874
+ agent_id: str,
1875
+ status: Optional[TimeOffStatus] = None,
1876
+ start_after: Optional[datetime.datetime] = None,
1877
+ end_before: Optional[datetime.datetime] = None
1878
+ ) -> List[TimeOffRequest]:
1879
+ """Get all time-off requests for an agent."""
1880
+ query = {"agent_id": agent_id}
1881
+
1882
+ if status:
1883
+ query["status"] = status
1884
+
1885
+ if start_after or end_before:
1886
+ time_query = {}
1887
+ if start_after:
1888
+ time_query["$gte"] = start_after
1889
+ if end_before:
1890
+ time_query["$lte"] = end_before
1891
+ query["start_time"] = time_query
1892
+
1893
+ data = self.db.find("time_off_requests", query,
1894
+ sort=[("start_time", 1)])
1895
+ return [TimeOffRequest(**item) for item in data]
1896
+
1897
+ def get_all_time_off_requests(
1898
+ self,
1899
+ status: Optional[TimeOffStatus] = None,
1900
+ start_after: Optional[datetime.datetime] = None,
1901
+ end_before: Optional[datetime.datetime] = None
1902
+ ) -> List[TimeOffRequest]:
1903
+ """Get all time-off requests, optionally filtered."""
1904
+ query = {}
1905
+
1906
+ if status:
1907
+ query["status"] = status
1908
+
1909
+ if start_after or end_before:
1910
+ time_query = {}
1911
+ if start_after:
1912
+ time_query["$gte"] = start_after
1913
+ if end_before:
1914
+ time_query["$lte"] = end_before
1915
+ query["start_time"] = time_query
1916
+
1917
+ data = self.db.find("time_off_requests", query,
1918
+ sort=[("start_time", 1)])
1919
+ return [TimeOffRequest(**item) for item in data]
1920
+
1540
1921
  #############################################
1541
1922
  # SERVICES
1542
1923
  #############################################
@@ -2441,6 +2822,27 @@ class TaskPlanningService:
2441
2822
  )
2442
2823
  self.ticket_repository.create(new_ticket)
2443
2824
 
2825
+ # After creating subtasks, schedule them if scheduling_service is available
2826
+ if hasattr(self, 'scheduling_service') and self.scheduling_service:
2827
+ # Schedule each subtask
2828
+ for subtask in subtasks:
2829
+ # Convert SubtaskModel to ScheduledTask
2830
+ scheduled_task = ScheduledTask(
2831
+ task_id=subtask.id,
2832
+ parent_id=ticket_id,
2833
+ title=subtask.title,
2834
+ description=subtask.description,
2835
+ estimated_minutes=subtask.estimated_minutes,
2836
+ priority=5, # Default priority
2837
+ assigned_to=subtask.assignee,
2838
+ status="pending",
2839
+ dependencies=subtask.dependencies,
2840
+ specialization_tags=[] # Can be enhanced with auto-detection
2841
+ )
2842
+
2843
+ # Try to schedule the task
2844
+ await self.scheduling_service.schedule_task(scheduled_task)
2845
+
2444
2846
  return subtasks
2445
2847
 
2446
2848
  except Exception as e:
@@ -3311,6 +3713,714 @@ class ProjectSimulationService:
3311
3713
  return f"NEEDS FURTHER ASSESSMENT:{historical_context} with a feasibility score of {feasibility_score:.1f}% and {risk_level} risk level, this project requires more detailed evaluation before proceeding.{load_context}"
3312
3714
 
3313
3715
 
3716
+ class SchedulingService:
3717
+ """Service for intelligent task scheduling and agent coordination."""
3718
+
3719
+ def __init__(
3720
+ self,
3721
+ scheduling_repository: SchedulingRepository,
3722
+ task_planning_service: TaskPlanningService = None,
3723
+ agent_service: AgentService = None
3724
+ ):
3725
+ self.repository = scheduling_repository
3726
+ self.task_planning_service = task_planning_service
3727
+ self.agent_service = agent_service
3728
+
3729
+ async def schedule_task(
3730
+ self,
3731
+ task: ScheduledTask,
3732
+ preferred_agent_id: str = None
3733
+ ) -> ScheduledTask:
3734
+ """Schedule a task with optimal time and agent assignment."""
3735
+ # First check if task already has a fixed schedule
3736
+ if task.scheduled_start and task.scheduled_end and task.assigned_to:
3737
+ # Task is already fully scheduled, just save it
3738
+ self.repository.update_scheduled_task(task)
3739
+ return task
3740
+
3741
+ # Find best agent for task based on specialization and availability
3742
+ if not task.assigned_to:
3743
+ task.assigned_to = await self._find_optimal_agent(task, preferred_agent_id)
3744
+
3745
+ # Find optimal time slot
3746
+ if not (task.scheduled_start and task.scheduled_end):
3747
+ time_window = await self._find_optimal_time_slot(task)
3748
+ if time_window:
3749
+ task.scheduled_start = time_window.start
3750
+ task.scheduled_end = time_window.end
3751
+
3752
+ # Update task status
3753
+ if task.status == "pending":
3754
+ task.status = "scheduled"
3755
+
3756
+ # Save the scheduled task
3757
+ self.repository.update_scheduled_task(task)
3758
+
3759
+ # Log scheduling event
3760
+ self._log_scheduling_event(
3761
+ "task_scheduled",
3762
+ task.task_id,
3763
+ task.assigned_to,
3764
+ {"scheduled_start": task.scheduled_start.isoformat()
3765
+ if task.scheduled_start else None}
3766
+ )
3767
+
3768
+ return task
3769
+
3770
+ async def optimize_schedule(self) -> Dict[str, Any]:
3771
+ """Optimize the entire schedule to maximize efficiency."""
3772
+ # Get all pending and scheduled tasks
3773
+ pending_tasks = self.repository.get_unscheduled_tasks()
3774
+ scheduled_tasks = self.repository.get_tasks_by_status("scheduled")
3775
+
3776
+ # Sort tasks by priority and dependencies
3777
+ sorted_tasks = self._sort_tasks_by_priority_and_dependencies(
3778
+ pending_tasks + scheduled_tasks
3779
+ )
3780
+
3781
+ # Get all agent schedules
3782
+ agent_schedules = self.repository.get_all_agent_schedules()
3783
+ agent_schedule_map = {
3784
+ schedule.agent_id: schedule for schedule in agent_schedules}
3785
+
3786
+ # Track changes for reporting
3787
+ changes = {
3788
+ "rescheduled_tasks": [],
3789
+ "reassigned_tasks": [],
3790
+ "unresolvable_conflicts": []
3791
+ }
3792
+
3793
+ # Process each task in priority order
3794
+ for task in sorted_tasks:
3795
+ # Skip completed tasks
3796
+ if task.status in ["completed", "cancelled"]:
3797
+ continue
3798
+
3799
+ original_agent = task.assigned_to
3800
+ original_start = task.scheduled_start
3801
+
3802
+ # Find optimal agent and time
3803
+ best_agent_id = await self._find_optimal_agent(task)
3804
+
3805
+ # If agent changed, update assignment
3806
+ if best_agent_id != original_agent and best_agent_id is not None:
3807
+ task.assigned_to = best_agent_id
3808
+ changes["reassigned_tasks"].append({
3809
+ "task_id": task.task_id,
3810
+ "original_agent": original_agent,
3811
+ "new_agent": best_agent_id
3812
+ })
3813
+
3814
+ # Find best time slot for this agent
3815
+ if task.assigned_to:
3816
+ # Use the agent's schedule from our map if available
3817
+ agent_schedule = agent_schedule_map.get(task.assigned_to)
3818
+
3819
+ # Find optimal time considering the agent's schedule - pass the cached schedule
3820
+ time_window = await self._find_optimal_time_slot(task, agent_schedule)
3821
+
3822
+ if time_window and (
3823
+ not original_start or
3824
+ time_window.start != original_start
3825
+ ):
3826
+ task.scheduled_start = time_window.start
3827
+ task.scheduled_end = time_window.end
3828
+ changes["rescheduled_tasks"].append({
3829
+ "task_id": task.task_id,
3830
+ "original_time": original_start.isoformat() if original_start else None,
3831
+ "new_time": time_window.start.isoformat()
3832
+ })
3833
+ else:
3834
+ changes["unresolvable_conflicts"].append({
3835
+ "task_id": task.task_id,
3836
+ "reason": "No suitable agent found"
3837
+ })
3838
+
3839
+ # Save changes
3840
+ self.repository.update_scheduled_task(task)
3841
+
3842
+ async def register_agent_schedule(self, schedule: AgentSchedule) -> bool:
3843
+ """Register or update an agent's schedule."""
3844
+ return self.repository.save_agent_schedule(schedule)
3845
+
3846
+ async def get_agent_schedule(self, agent_id: str) -> Optional[AgentSchedule]:
3847
+ """Get an agent's schedule."""
3848
+ return self.repository.get_agent_schedule(agent_id)
3849
+
3850
+ async def get_agent_tasks(
3851
+ self,
3852
+ agent_id: str,
3853
+ start_time: Optional[datetime.datetime] = None,
3854
+ end_time: Optional[datetime.datetime] = None,
3855
+ include_completed: bool = False
3856
+ ) -> List[ScheduledTask]:
3857
+ """Get all tasks scheduled for an agent within a time range."""
3858
+ status_filter = None if include_completed else "scheduled"
3859
+ return self.repository.get_agent_tasks(agent_id, start_time, end_time, status_filter)
3860
+
3861
+ async def mark_task_started(self, task_id: str) -> bool:
3862
+ """Mark a task as started."""
3863
+ task = self.repository.get_scheduled_task(task_id)
3864
+ if not task:
3865
+ return False
3866
+
3867
+ task.status = "in_progress"
3868
+ task.actual_start = datetime.datetime.now(datetime.timezone.utc)
3869
+ self.repository.update_scheduled_task(task)
3870
+
3871
+ self._log_scheduling_event(
3872
+ "task_started",
3873
+ task_id,
3874
+ task.assigned_to,
3875
+ {"actual_start": task.actual_start.isoformat()}
3876
+ )
3877
+
3878
+ return True
3879
+
3880
+ async def mark_task_completed(self, task_id: str) -> bool:
3881
+ """Mark a task as completed."""
3882
+ task = self.repository.get_scheduled_task(task_id)
3883
+ if not task:
3884
+ return False
3885
+
3886
+ task.status = "completed"
3887
+ task.actual_end = datetime.datetime.now(datetime.timezone.utc)
3888
+ self.repository.update_scheduled_task(task)
3889
+
3890
+ # Calculate metrics
3891
+ duration_minutes = 0
3892
+ if task.actual_start:
3893
+ duration_minutes = int(
3894
+ (task.actual_end - task.actual_start).total_seconds() / 60)
3895
+
3896
+ estimated_minutes = task.estimated_minutes or 0
3897
+ accuracy = 0
3898
+ if estimated_minutes > 0 and duration_minutes > 0:
3899
+ # Calculate how accurate the estimate was (1.0 = perfect, <1.0 = underestimate, >1.0 = overestimate)
3900
+ accuracy = estimated_minutes / duration_minutes
3901
+
3902
+ self._log_scheduling_event(
3903
+ "task_completed",
3904
+ task_id,
3905
+ task.assigned_to,
3906
+ {
3907
+ "actual_end": task.actual_end.isoformat(),
3908
+ "duration_minutes": duration_minutes,
3909
+ "estimated_minutes": estimated_minutes,
3910
+ "estimate_accuracy": accuracy
3911
+ }
3912
+ )
3913
+
3914
+ return True
3915
+
3916
+ async def find_available_time_slots(
3917
+ self,
3918
+ agent_id: str,
3919
+ duration_minutes: int,
3920
+ start_after: datetime.datetime = None,
3921
+ end_before: datetime.datetime = None,
3922
+ count: int = 3,
3923
+ agent_schedule: Optional[AgentSchedule] = None
3924
+ ) -> List[TimeWindow]:
3925
+ """Find available time slots for an agent."""
3926
+ # Default time bounds
3927
+ if not start_after:
3928
+ start_after = datetime.datetime.now(datetime.timezone.utc)
3929
+ if not end_before:
3930
+ end_before = start_after + datetime.timedelta(days=7)
3931
+
3932
+ # Get agent schedule if not provided
3933
+ if not agent_schedule:
3934
+ agent_schedule = self.repository.get_agent_schedule(agent_id)
3935
+ if not agent_schedule:
3936
+ return []
3937
+
3938
+ # Rest of method unchanged...
3939
+ async def resolve_scheduling_conflicts(self) -> Dict[str, Any]:
3940
+ """Detect and resolve scheduling conflicts."""
3941
+ # Get all scheduled tasks
3942
+ tasks = self.repository.get_tasks_by_status("scheduled")
3943
+
3944
+ # Group tasks by agent
3945
+ agent_tasks = {}
3946
+ for task in tasks:
3947
+ if task.assigned_to:
3948
+ if task.assigned_to not in agent_tasks:
3949
+ agent_tasks[task.assigned_to] = []
3950
+ agent_tasks[task.assigned_to].append(task)
3951
+
3952
+ # Check for conflicts within each agent's schedule
3953
+ conflicts = []
3954
+ for agent_id, agent_task_list in agent_tasks.items():
3955
+ # Sort tasks by start time
3956
+ agent_task_list.sort(
3957
+ key=lambda t: t.scheduled_start or datetime.datetime.max)
3958
+
3959
+ # Check for overlaps
3960
+ for i in range(len(agent_task_list) - 1):
3961
+ current = agent_task_list[i]
3962
+ next_task = agent_task_list[i + 1]
3963
+
3964
+ if (current.scheduled_start and current.scheduled_end and
3965
+ next_task.scheduled_start and next_task.scheduled_end):
3966
+
3967
+ current_window = TimeWindow(
3968
+ start=current.scheduled_start, end=current.scheduled_end)
3969
+ next_window = TimeWindow(
3970
+ start=next_task.scheduled_start, end=next_task.scheduled_end)
3971
+
3972
+ if current_window.overlaps_with(next_window):
3973
+ conflicts.append({
3974
+ "agent_id": agent_id,
3975
+ "task1": current.task_id,
3976
+ "task2": next_task.task_id,
3977
+ "start1": current.scheduled_start.isoformat(),
3978
+ "end1": current.scheduled_end.isoformat(),
3979
+ "start2": next_task.scheduled_start.isoformat(),
3980
+ "end2": next_task.scheduled_end.isoformat()
3981
+ })
3982
+
3983
+ # Try to resolve by moving the second task later
3984
+ next_task.scheduled_start = current.scheduled_end
3985
+ next_task.scheduled_end = next_task.scheduled_start + datetime.timedelta(
3986
+ minutes=next_task.estimated_minutes or 30
3987
+ )
3988
+ self.repository.update_scheduled_task(next_task)
3989
+
3990
+ # Log conflict resolution
3991
+ if conflicts:
3992
+ self._log_scheduling_event(
3993
+ "conflicts_resolved",
3994
+ None,
3995
+ None,
3996
+ {"conflict_count": len(conflicts)}
3997
+ )
3998
+
3999
+ return {"conflicts_found": len(conflicts), "conflicts": conflicts}
4000
+
4001
+ async def _find_optimal_agent(
4002
+ self,
4003
+ task: ScheduledTask,
4004
+ preferred_agent_id: str = None,
4005
+ excluded_agents: List[str] = None
4006
+ ) -> Optional[str]:
4007
+ """Find the optimal agent for a task based on specialization and availability."""
4008
+ if not self.agent_service:
4009
+ return preferred_agent_id
4010
+
4011
+ # Initialize excluded agents list if not provided
4012
+ excluded_agents = excluded_agents or []
4013
+
4014
+ # Get the specializations required for this task
4015
+ required_specializations = task.specialization_tags
4016
+
4017
+ # Get all agent specializations
4018
+ agent_specializations = self.agent_service.get_specializations()
4019
+
4020
+ # Start with the preferred agent if specified and not excluded
4021
+ if preferred_agent_id and preferred_agent_id not in excluded_agents:
4022
+ # Check if preferred agent has the required specialization
4023
+ if preferred_agent_id in agent_specializations:
4024
+ agent_spec = agent_specializations[preferred_agent_id]
4025
+ for req_spec in required_specializations:
4026
+ if req_spec.lower() in agent_spec.lower():
4027
+ # Check if the agent is available
4028
+ schedule = self.repository.get_agent_schedule(
4029
+ preferred_agent_id)
4030
+ if schedule and (not task.scheduled_start or schedule.is_available_at(task.scheduled_start)):
4031
+ return preferred_agent_id # Missing return statement was here
4032
+
4033
+ # Rank all agents based on specialization match and availability
4034
+ candidates = []
4035
+
4036
+ # First, check AI agents (they typically have higher availability)
4037
+ for agent_id, specialization in agent_specializations.items():
4038
+ # Skip excluded agents
4039
+ if agent_id in excluded_agents:
4040
+ continue
4041
+
4042
+ # Skip if we know it's a human agent (they have different availability patterns)
4043
+ is_human = False
4044
+ if self.agent_service.human_agent_registry:
4045
+ human_agents = self.agent_service.human_agent_registry.get_all_human_agents()
4046
+ is_human = agent_id in human_agents
4047
+
4048
+ if is_human:
4049
+ continue
4050
+
4051
+ # Calculate specialization match score
4052
+ spec_match_score = 0
4053
+ for req_spec in required_specializations:
4054
+ if req_spec.lower() in specialization.lower():
4055
+ spec_match_score += 1
4056
+
4057
+ # Only consider agents with at least some specialization match
4058
+ if spec_match_score > 0:
4059
+ candidates.append({
4060
+ "agent_id": agent_id,
4061
+ "score": spec_match_score,
4062
+ "is_human": is_human
4063
+ })
4064
+
4065
+ # Then, check human agents (they typically have more limited availability)
4066
+ for agent_id, specialization in agent_specializations.items():
4067
+ # Skip excluded agents
4068
+ if agent_id in excluded_agents:
4069
+ continue
4070
+
4071
+ # Skip if not a human agent
4072
+ is_human = False
4073
+ if self.agent_service.human_agent_registry:
4074
+ human_agents = self.agent_service.human_agent_registry.get_all_human_agents()
4075
+ is_human = agent_id in human_agents
4076
+
4077
+ if not is_human:
4078
+ continue
4079
+
4080
+ # Calculate specialization match score
4081
+ spec_match_score = 0
4082
+ for req_spec in required_specializations:
4083
+ if req_spec.lower() in specialization.lower():
4084
+ spec_match_score += 1
4085
+
4086
+ # Only consider agents with at least some specialization match
4087
+ if spec_match_score > 0:
4088
+ candidates.append({
4089
+ "agent_id": agent_id,
4090
+ "score": spec_match_score,
4091
+ "is_human": is_human
4092
+ })
4093
+
4094
+ # Sort candidates by score (descending)
4095
+ candidates.sort(key=lambda c: c["score"], reverse=True)
4096
+
4097
+ # Check availability for each candidate
4098
+ for candidate in candidates:
4099
+ agent_id = candidate["agent_id"]
4100
+
4101
+ # Check if the agent has a schedule
4102
+ schedule = self.repository.get_agent_schedule(agent_id)
4103
+
4104
+ # If no schedule or no specific start time yet, assume available
4105
+ if not schedule or not task.scheduled_start:
4106
+ return agent_id
4107
+
4108
+ # Check availability at the scheduled time
4109
+ if schedule.is_available_at(task.scheduled_start):
4110
+ return agent_id
4111
+
4112
+ # If no good match found, return None
4113
+ return None
4114
+
4115
+ async def _find_optimal_time_slot(
4116
+ self,
4117
+ task: ScheduledTask,
4118
+ agent_schedule: Optional[AgentSchedule] = None
4119
+ ) -> Optional[TimeWindow]:
4120
+ """Find the optimal time slot for a task based on constraints and agent availability."""
4121
+ if not task.assigned_to:
4122
+ return None
4123
+
4124
+ agent_id = task.assigned_to
4125
+ duration = task.estimated_minutes or 30
4126
+
4127
+ # Start no earlier than now
4128
+ start_after = datetime.datetime.now(datetime.timezone.utc)
4129
+
4130
+ # Apply task constraints
4131
+ for constraint in task.constraints:
4132
+ if constraint.get("type") == "must_start_after" and constraint.get("time"):
4133
+ constraint_time = datetime.datetime.fromisoformat(
4134
+ constraint["time"])
4135
+ if constraint_time > start_after:
4136
+ start_after = constraint_time
4137
+
4138
+ # Get available slots - use provided agent_schedule if available
4139
+ available_slots = await self.find_available_time_slots(
4140
+ agent_id,
4141
+ duration,
4142
+ start_after,
4143
+ count=1,
4144
+ agent_schedule=agent_schedule
4145
+ )
4146
+
4147
+ # Return the first available slot, if any
4148
+ return available_slots[0] if available_slots else None
4149
+
4150
+ def _sort_tasks_by_priority_and_dependencies(self, tasks: List[ScheduledTask]) -> List[ScheduledTask]:
4151
+ """Sort tasks by priority and dependencies."""
4152
+ # First, build dependency graph
4153
+ task_map = {task.task_id: task for task in tasks}
4154
+ dependency_graph = {task.task_id: set(
4155
+ task.dependencies) for task in tasks}
4156
+
4157
+ # Calculate priority score (higher is more important)
4158
+ def calculate_priority_score(task):
4159
+ base_priority = task.priority or 5
4160
+
4161
+ # Increase priority for tasks with deadlines
4162
+ urgency_bonus = 0
4163
+ if task.scheduled_end:
4164
+ # How soon is the deadline?
4165
+ time_until_deadline = (
4166
+ task.scheduled_end - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
4167
+ # Convert to hours
4168
+ hours_remaining = max(0, time_until_deadline / 3600)
4169
+
4170
+ # More urgent as deadline approaches
4171
+ if hours_remaining < 24:
4172
+ urgency_bonus = 5 # Very urgent: <24h
4173
+ elif hours_remaining < 48:
4174
+ urgency_bonus = 3 # Urgent: 1-2 days
4175
+ elif hours_remaining < 72:
4176
+ urgency_bonus = 1 # Somewhat urgent: 2-3 days
4177
+
4178
+ # Increase priority for blocking tasks
4179
+ dependency_count = 0
4180
+ for other_task_id, deps in dependency_graph.items():
4181
+ if task.task_id in deps:
4182
+ dependency_count += 1
4183
+
4184
+ blocking_bonus = min(dependency_count, 5) # Cap at +5
4185
+
4186
+ return base_priority + urgency_bonus + blocking_bonus
4187
+
4188
+ # Assign priority scores
4189
+ for task in tasks:
4190
+ task.priority_score = calculate_priority_score(task)
4191
+
4192
+ # Sort by priority score (descending)
4193
+ sorted_tasks = sorted(
4194
+ tasks, key=lambda t: t.priority_score, reverse=True)
4195
+
4196
+ # Move tasks with dependencies after their dependencies
4197
+ final_order = []
4198
+ processed = set()
4199
+
4200
+ def process_task(task_id):
4201
+ if task_id in processed:
4202
+ return
4203
+
4204
+ # First process all dependencies
4205
+ for dep_id in dependency_graph.get(task_id, []):
4206
+ if dep_id in task_map: # Skip if dependency doesn't exist
4207
+ process_task(dep_id)
4208
+
4209
+ # Now add this task
4210
+ if task_id in task_map: # Make sure task exists
4211
+ final_order.append(task_map[task_id])
4212
+ processed.add(task_id)
4213
+
4214
+ # Process all tasks
4215
+ for task in sorted_tasks:
4216
+ process_task(task.task_id)
4217
+
4218
+ return final_order
4219
+
4220
+ def _log_scheduling_event(
4221
+ self,
4222
+ event_type: str,
4223
+ task_id: Optional[str] = None,
4224
+ agent_id: Optional[str] = None,
4225
+ details: Dict[str, Any] = None
4226
+ ) -> None:
4227
+ """Log a scheduling event."""
4228
+ event = SchedulingEvent(
4229
+ event_type=event_type,
4230
+ task_id=task_id,
4231
+ agent_id=agent_id,
4232
+ details=details or {}
4233
+ )
4234
+ self.repository.log_scheduling_event(event)
4235
+
4236
+ async def request_time_off(
4237
+ self,
4238
+ agent_id: str,
4239
+ start_time: datetime.datetime,
4240
+ end_time: datetime.datetime,
4241
+ reason: str
4242
+ ) -> Tuple[bool, str, Optional[str]]:
4243
+ """
4244
+ Request time off for a human agent.
4245
+
4246
+ Returns:
4247
+ Tuple of (success, status, request_id)
4248
+ """
4249
+ # Create the request object
4250
+ request = TimeOffRequest(
4251
+ agent_id=agent_id,
4252
+ start_time=start_time,
4253
+ end_time=end_time,
4254
+ reason=reason
4255
+ )
4256
+
4257
+ # Store the request
4258
+ self.repository.create_time_off_request(request)
4259
+
4260
+ # Process the request automatically
4261
+ return await self._process_time_off_request(request)
4262
+
4263
+ async def _process_time_off_request(
4264
+ self,
4265
+ request: TimeOffRequest
4266
+ ) -> Tuple[bool, str, Optional[str]]:
4267
+ """
4268
+ Process a time off request automatically.
4269
+
4270
+ Returns:
4271
+ Tuple of (success, status, request_id)
4272
+ """
4273
+ # Get affected tasks during this time period
4274
+ affected_tasks = self.repository.get_agent_tasks(
4275
+ request.agent_id,
4276
+ request.start_time,
4277
+ request.end_time,
4278
+ "scheduled"
4279
+ )
4280
+
4281
+ # Check if we can reassign all affected tasks
4282
+ reassignable_tasks = []
4283
+ non_reassignable_tasks = []
4284
+
4285
+ for task in affected_tasks:
4286
+ # For each affected task, check if we can find another suitable agent
4287
+ alternate_agent = await self._find_optimal_agent(
4288
+ task,
4289
+ excluded_agents=[request.agent_id]
4290
+ )
4291
+
4292
+ if alternate_agent:
4293
+ reassignable_tasks.append((task, alternate_agent))
4294
+ else:
4295
+ non_reassignable_tasks.append(task)
4296
+
4297
+ # Make approval decision
4298
+ approval_threshold = 0.8 # We require 80% of tasks to be reassignable
4299
+
4300
+ if len(affected_tasks) == 0 or (
4301
+ len(reassignable_tasks) / len(affected_tasks) >= approval_threshold
4302
+ ):
4303
+ # Approve the request
4304
+ request.status = TimeOffStatus.APPROVED
4305
+ self.repository.update_time_off_request(request)
4306
+
4307
+ # Create unavailability window in agent's schedule
4308
+ agent_schedule = self.repository.get_agent_schedule(
4309
+ request.agent_id)
4310
+ if agent_schedule:
4311
+ time_off_window = TimeWindow(
4312
+ start=request.start_time,
4313
+ end=request.end_time
4314
+ )
4315
+ agent_schedule.availability_exceptions.append(time_off_window)
4316
+ self.repository.save_agent_schedule(agent_schedule)
4317
+
4318
+ # Reassign tasks that can be reassigned
4319
+ for task, new_agent in reassignable_tasks:
4320
+ task.assigned_to = new_agent
4321
+ self.repository.update_scheduled_task(task)
4322
+
4323
+ self._log_scheduling_event(
4324
+ "task_reassigned_time_off",
4325
+ task.task_id,
4326
+ request.agent_id,
4327
+ {
4328
+ "original_agent": request.agent_id,
4329
+ "new_agent": new_agent,
4330
+ "time_off_request_id": request.request_id
4331
+ }
4332
+ )
4333
+
4334
+ # For tasks that can't be reassigned, mark them for review
4335
+ for task in non_reassignable_tasks:
4336
+ self._log_scheduling_event(
4337
+ "task_needs_reassignment",
4338
+ task.task_id,
4339
+ request.agent_id,
4340
+ {
4341
+ "time_off_request_id": request.request_id,
4342
+ "reason": "Cannot find suitable replacement agent"
4343
+ }
4344
+ )
4345
+
4346
+ return (True, "approved", request.request_id)
4347
+ else:
4348
+ # Reject the request
4349
+ request.status = TimeOffStatus.REJECTED
4350
+ request.rejection_reason = f"Cannot reassign {len(non_reassignable_tasks)} critical tasks during requested time period."
4351
+ self.repository.update_time_off_request(request)
4352
+
4353
+ return (False, "rejected", request.request_id)
4354
+
4355
+ async def cancel_time_off_request(
4356
+ self,
4357
+ agent_id: str,
4358
+ request_id: str
4359
+ ) -> Tuple[bool, str]:
4360
+ """
4361
+ Cancel a time off request.
4362
+
4363
+ Returns:
4364
+ Tuple of (success, status)
4365
+ """
4366
+ # Get the request
4367
+ request = self.repository.get_time_off_request(request_id)
4368
+
4369
+ if not request:
4370
+ return (False, "not_found")
4371
+
4372
+ if request.agent_id != agent_id:
4373
+ return (False, "unauthorized")
4374
+
4375
+ if request.status not in [TimeOffStatus.REQUESTED, TimeOffStatus.APPROVED]:
4376
+ return (False, "invalid_status")
4377
+
4378
+ # Check if the time off has already started
4379
+ now = datetime.datetime.now(datetime.timezone.utc)
4380
+ if request.status == TimeOffStatus.APPROVED and request.start_time <= now:
4381
+ return (False, "already_started")
4382
+
4383
+ # Cancel the request
4384
+ request.status = TimeOffStatus.CANCELLED
4385
+ self.repository.update_time_off_request(request)
4386
+
4387
+ # If it was approved, also remove from agent's schedule
4388
+ if request.status == TimeOffStatus.APPROVED:
4389
+ agent_schedule = self.repository.get_agent_schedule(agent_id)
4390
+ if agent_schedule:
4391
+ # Remove the exception for this time off period
4392
+ agent_schedule.availability_exceptions = [
4393
+ exception for exception in agent_schedule.availability_exceptions
4394
+ if not (exception.start == request.start_time and
4395
+ exception.end == request.end_time)
4396
+ ]
4397
+ self.repository.save_agent_schedule(agent_schedule)
4398
+
4399
+ return (True, "cancelled")
4400
+
4401
+ async def get_agent_time_off_history(
4402
+ self,
4403
+ agent_id: str
4404
+ ) -> List[Dict[str, Any]]:
4405
+ """Get an agent's time off history."""
4406
+ requests = self.repository.get_agent_time_off_requests(agent_id)
4407
+
4408
+ # Format for display
4409
+ formatted_requests = []
4410
+ for request in requests:
4411
+ formatted_requests.append({
4412
+ "request_id": request.request_id,
4413
+ "start_time": request.start_time.isoformat(),
4414
+ "end_time": request.end_time.isoformat(),
4415
+ "duration_hours": (request.end_time - request.start_time).total_seconds() / 3600,
4416
+ "reason": request.reason,
4417
+ "status": request.status,
4418
+ "created_at": request.created_at.isoformat(),
4419
+ "rejection_reason": request.rejection_reason
4420
+ })
4421
+
4422
+ return formatted_requests
4423
+
3314
4424
  #############################################
3315
4425
  # MAIN AGENT PROCESSOR
3316
4426
  #############################################
@@ -3335,6 +4445,7 @@ class QueryProcessor:
3335
4445
  project_approval_service: Optional[ProjectApprovalService] = None,
3336
4446
  project_simulation_service: Optional[ProjectSimulationService] = None,
3337
4447
  require_human_approval: bool = False,
4448
+ scheduling_service: Optional[SchedulingService] = None,
3338
4449
  ):
3339
4450
  self.agent_service = agent_service
3340
4451
  self.routing_service = routing_service
@@ -3351,6 +4462,7 @@ class QueryProcessor:
3351
4462
  self.project_simulation_service = project_simulation_service
3352
4463
  self.require_human_approval = require_human_approval
3353
4464
  self._shutdown_event = asyncio.Event()
4465
+ self.scheduling_service = scheduling_service
3354
4466
 
3355
4467
  async def process(
3356
4468
  self, user_id: str, user_text: str, timezone: str = None
@@ -3627,6 +4739,155 @@ class QueryProcessor:
3627
4739
  )
3628
4740
  return f"Project {ticket_id} has been {'approved' if approved else 'rejected'}."
3629
4741
 
4742
+ elif command == "!schedule" and args and self.scheduling_service:
4743
+ # Format: !schedule task_id [agent_id] [YYYY-MM-DD HH:MM]
4744
+ parts = args.strip().split(" ", 2)
4745
+ if len(parts) < 1:
4746
+ return "Usage: !schedule task_id [agent_id] [YYYY-MM-DD HH:MM]"
4747
+
4748
+ task_id = parts[0]
4749
+ agent_id = parts[1] if len(parts) > 1 else None
4750
+ time_str = parts[2] if len(parts) > 2 else None
4751
+
4752
+ # Fetch the task from ticket repository
4753
+ ticket = self.ticket_service.ticket_repository.get_by_id(task_id)
4754
+ if not ticket:
4755
+ return f"Task {task_id} not found."
4756
+
4757
+ # Convert ticket to scheduled task
4758
+ scheduled_task = ScheduledTask(
4759
+ task_id=task_id,
4760
+ title=ticket.query[:50] +
4761
+ "..." if len(ticket.query) > 50 else ticket.query,
4762
+ description=ticket.query,
4763
+ estimated_minutes=ticket.complexity.get(
4764
+ "estimated_minutes", 30) if ticket.complexity else 30,
4765
+ priority=5, # Default priority
4766
+ assigned_to=agent_id or ticket.assigned_to,
4767
+ # Use current agent as a specialization tag
4768
+ specialization_tags=[ticket.assigned_to],
4769
+ )
4770
+
4771
+ # Set scheduled time if provided
4772
+ if time_str:
4773
+ try:
4774
+ scheduled_time = datetime.datetime.fromisoformat(time_str)
4775
+ scheduled_task.scheduled_start = scheduled_time
4776
+ scheduled_task.scheduled_end = scheduled_time + datetime.timedelta(
4777
+ minutes=scheduled_task.estimated_minutes
4778
+ )
4779
+ except ValueError:
4780
+ return "Invalid date format. Use YYYY-MM-DD HH:MM."
4781
+
4782
+ # Schedule the task
4783
+ result = await self.scheduling_service.schedule_task(
4784
+ scheduled_task, preferred_agent_id=agent_id
4785
+ )
4786
+
4787
+ # Update ticket with scheduling info
4788
+ self.ticket_service.update_ticket_status(
4789
+ task_id,
4790
+ ticket.status,
4791
+ scheduled_start=result.scheduled_start,
4792
+ scheduled_agent=result.assigned_to
4793
+ )
4794
+
4795
+ # Format response
4796
+ response = "# Task Scheduled\n\n"
4797
+ response += f"**Task:** {scheduled_task.title}\n"
4798
+ response += f"**Assigned to:** {result.assigned_to}\n"
4799
+ response += f"**Scheduled start:** {result.scheduled_start.strftime('%Y-%m-%d %H:%M')}\n"
4800
+ response += f"**Estimated duration:** {result.estimated_minutes} minutes"
4801
+
4802
+ return response
4803
+
4804
+ elif command == "!timeoff" and args and self.scheduling_service:
4805
+ # Format: !timeoff request YYYY-MM-DD HH:MM YYYY-MM-DD HH:MM reason
4806
+ # or: !timeoff cancel request_id
4807
+ parts = args.strip().split(" ", 1)
4808
+ if len(parts) < 2:
4809
+ return "Usage: \n- !timeoff request START_DATE END_DATE reason\n- !timeoff cancel request_id"
4810
+
4811
+ action = parts[0].lower()
4812
+ action_args = parts[1]
4813
+
4814
+ if action == "request":
4815
+ # Parse request args
4816
+ request_parts = action_args.split(" ", 2)
4817
+ if len(request_parts) < 3:
4818
+ return "Usage: !timeoff request YYYY-MM-DD YYYY-MM-DD reason"
4819
+
4820
+ start_str = request_parts[0]
4821
+ end_str = request_parts[1]
4822
+ reason = request_parts[2]
4823
+
4824
+ try:
4825
+ start_time = datetime.datetime.fromisoformat(start_str)
4826
+ end_time = datetime.datetime.fromisoformat(end_str)
4827
+ except ValueError:
4828
+ return "Invalid date format. Use YYYY-MM-DD HH:MM."
4829
+
4830
+ # Submit time off request
4831
+ success, status, request_id = await self.scheduling_service.request_time_off(
4832
+ user_id, start_time, end_time, reason
4833
+ )
4834
+
4835
+ if success:
4836
+ return f"Time off request submitted and automatically approved. Request ID: {request_id}"
4837
+ else:
4838
+ return f"Time off request {status}. Request ID: {request_id}"
4839
+
4840
+ elif action == "cancel":
4841
+ request_id = action_args.strip()
4842
+ success, status = await self.scheduling_service.cancel_time_off_request(
4843
+ user_id, request_id
4844
+ )
4845
+
4846
+ if success:
4847
+ return f"Time off request {request_id} cancelled successfully."
4848
+ else:
4849
+ return f"Failed to cancel request: {status}"
4850
+
4851
+ return "Unknown timeoff action. Use 'request' or 'cancel'."
4852
+
4853
+ elif command == "!schedule-view" and self.scheduling_service:
4854
+ # View agent's schedule for the next week
4855
+ # Default to current user if no agent specified
4856
+ agent_id = args.strip() if args else user_id
4857
+
4858
+ start_time = datetime.datetime.now(datetime.timezone.utc)
4859
+ end_time = start_time + datetime.timedelta(days=7)
4860
+
4861
+ # Get tasks for the specified time period
4862
+ tasks = await self.scheduling_service.get_agent_tasks(
4863
+ agent_id, start_time, end_time, include_completed=False
4864
+ )
4865
+
4866
+ if not tasks:
4867
+ return f"No scheduled tasks found for {agent_id} in the next 7 days."
4868
+
4869
+ # Sort by start time
4870
+ tasks.sort(key=lambda t: t.scheduled_start or datetime.datetime.max)
4871
+
4872
+ # Format response
4873
+ response = f"# Schedule for {agent_id}\n\n"
4874
+
4875
+ current_day = None
4876
+ for task in tasks:
4877
+ # Group by day
4878
+ task_day = task.scheduled_start.strftime(
4879
+ "%Y-%m-%d") if task.scheduled_start else "Unscheduled"
4880
+
4881
+ if task_day != current_day:
4882
+ response += f"\n## {task_day}\n\n"
4883
+ current_day = task_day
4884
+
4885
+ start_time = task.scheduled_start.strftime(
4886
+ "%H:%M") if task.scheduled_start else "TBD"
4887
+ response += f"- **{start_time}** ({task.estimated_minutes} min): {task.title}\n"
4888
+
4889
+ return response
4890
+
3630
4891
  return None
3631
4892
 
3632
4893
  async def _process_existing_ticket(
@@ -4263,9 +5524,21 @@ class SolanaAgentFactory:
4263
5524
  llm_adapter, task_planning_service
4264
5525
  )
4265
5526
 
4266
- # Initialize plugin system if plugins directory is configured
4267
- plugins_dir = config.get("plugins_dir", "plugins")
4268
- agent_service.plugin_manager = PluginManager(plugins_dir)
5527
+ # Create scheduling repository and service
5528
+ scheduling_repository = SchedulingRepository(db_adapter)
5529
+
5530
+ scheduling_service = SchedulingService(
5531
+ scheduling_repository=scheduling_repository,
5532
+ task_planning_service=task_planning_service,
5533
+ agent_service=agent_service
5534
+ )
5535
+
5536
+ # Update task_planning_service with scheduling_service if needed
5537
+ if task_planning_service:
5538
+ task_planning_service.scheduling_service = scheduling_service
5539
+
5540
+ # Initialize plugin system if plugins directory is configured)
5541
+ agent_service.plugin_manager = PluginManager()
4269
5542
  loaded_plugins = agent_service.plugin_manager.load_all_plugins()
4270
5543
  print(f"Loaded {loaded_plugins} plugins")
4271
5544
 
@@ -4316,6 +5589,7 @@ class SolanaAgentFactory:
4316
5589
  project_approval_service=project_approval_service,
4317
5590
  project_simulation_service=project_simulation_service,
4318
5591
  require_human_approval=config.get("require_human_approval", False),
5592
+ scheduling_service=scheduling_service
4319
5593
  )
4320
5594
 
4321
5595
  return query_processor
@@ -5028,8 +6302,7 @@ tool_registry = ToolRegistry()
5028
6302
  class PluginManager:
5029
6303
  """Manages discovery, loading and execution of plugins."""
5030
6304
 
5031
- def __init__(self, plugins_dir: str = "plugins"):
5032
- self.plugins_dir = plugins_dir
6305
+ def __init__(self):
5033
6306
  self.tools = {}
5034
6307
 
5035
6308
  def load_all_plugins(self) -> int: