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 +1279 -6
- {solana_agent-11.0.0.dist-info → solana_agent-11.1.0.dist-info}/METADATA +15 -2
- solana_agent-11.1.0.dist-info/RECORD +6 -0
- solana_agent-11.0.0.dist-info/RECORD +0 -6
- {solana_agent-11.0.0.dist-info → solana_agent-11.1.0.dist-info}/LICENSE +0 -0
- {solana_agent-11.0.0.dist-info → solana_agent-11.1.0.dist-info}/WHEEL +0 -0
solana_agent/ai.py
CHANGED
@@ -44,7 +44,161 @@ from abc import ABC, abstractmethod
|
|
44
44
|
# DOMAIN MODELS
|
45
45
|
#############################################
|
46
46
|
|
47
|
-
|
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
|
-
#
|
4267
|
-
|
4268
|
-
|
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
|
5032
|
-
self.plugins_dir = plugins_dir
|
6305
|
+
def __init__(self):
|
5033
6306
|
self.tools = {}
|
5034
6307
|
|
5035
6308
|
def load_all_plugins(self) -> int:
|