solana-agent 11.2.0__py3-none-any.whl → 12.0.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
@@ -12,6 +12,7 @@ This module implements a clean architecture approach with:
12
12
 
13
13
  import asyncio
14
14
  import datetime
15
+ import importlib
15
16
  import json
16
17
  import re
17
18
  import traceback
@@ -30,7 +31,7 @@ from typing import (
30
31
  Any,
31
32
  Type,
32
33
  )
33
- from pydantic import BaseModel, Field
34
+ from pydantic import BaseModel, Field, ValidationError
34
35
  from pymongo import MongoClient
35
36
  from openai import OpenAI
36
37
  import pymongo
@@ -45,6 +46,73 @@ from abc import ABC, abstractmethod
45
46
  # DOMAIN MODELS
46
47
  #############################################
47
48
 
49
+ class ToolCallModel(BaseModel):
50
+ """Model for tool calls in agent responses."""
51
+ name: str = Field(..., description="Name of the tool to execute")
52
+ parameters: Dict[str, Any] = Field(
53
+ default_factory=dict, description="Parameters for the tool")
54
+
55
+
56
+ class ToolInstructionModel(BaseModel):
57
+ """Model for tool usage instructions in system prompts."""
58
+ available_tools: List[Dict[str, Any]] = Field(default_factory=list,
59
+ description="Tools available to this agent")
60
+ example_tool: str = Field("search_internet",
61
+ description="Example tool to use in instructions")
62
+ example_query: str = Field("latest Solana news",
63
+ description="Example query to use in instructions")
64
+ valid_agents: List[str] = Field(default_factory=list,
65
+ description="List of valid agents for handoff")
66
+
67
+ def format_instructions(self) -> str:
68
+ """Format the tool and handoff instructions using plain text delimiters."""
69
+ tools_json = json.dumps(self.available_tools, indent=2)
70
+
71
+ # Tool usage instructions with plain text delimiters
72
+ tool_instructions = f"""
73
+ You have access to the following tools:
74
+ {tools_json}
75
+
76
+ IMPORTANT - TOOL USAGE: When you need to use a tool, respond with JSON using these exact plain text delimiters:
77
+
78
+ TOOL_START
79
+ {{
80
+ "name": "tool_name",
81
+ "parameters": {{
82
+ "param1": "value1",
83
+ "param2": "value2"
84
+ }}
85
+ }}
86
+ TOOL_END
87
+
88
+ Example: To search the internet for "{self.example_query}", respond with:
89
+
90
+ TOOL_START
91
+ {{
92
+ "name": "{self.example_tool}",
93
+ "parameters": {{
94
+ "query": "{self.example_query}"
95
+ }}
96
+ }}
97
+ TOOL_END
98
+
99
+ ALWAYS use the search_internet tool when the user asks for current information or facts that might be beyond your knowledge cutoff. DO NOT attempt to handoff for information that could be obtained using search_internet.
100
+ """
101
+
102
+ # Handoff instructions if valid agents are provided
103
+ handoff_instructions = ""
104
+ if self.valid_agents:
105
+ handoff_instructions = f"""
106
+ IMPORTANT - HANDOFFS: You can ONLY hand off to these existing agents: {", ".join(self.valid_agents)}
107
+ DO NOT invent or reference agents that don't exist in this list.
108
+
109
+ To hand off to another agent, use this format:
110
+ {{"handoff": {{"target_agent": "<AGENT_NAME_FROM_LIST_ABOVE>", "reason": "detailed reason for handoff"}}}}
111
+ """
112
+
113
+ return f"{tool_instructions}\n\n{handoff_instructions}"
114
+
115
+
48
116
  class AgentType(str, Enum):
49
117
  """Type of agent (AI or Human)."""
50
118
  AI = "ai"
@@ -300,22 +368,31 @@ class AgentType(str, Enum):
300
368
 
301
369
 
302
370
  class Ticket(BaseModel):
303
- """Represents a user support ticket."""
304
-
305
- id: str
371
+ """Model for a support ticket."""
372
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
306
373
  user_id: str
307
374
  query: str
308
- status: TicketStatus
309
- assigned_to: str
310
- created_at: datetime.datetime
375
+ status: TicketStatus = TicketStatus.NEW
376
+ assigned_to: str = ""
377
+ created_at: datetime.datetime = Field(
378
+ default_factory=lambda: datetime.datetime.now(datetime.timezone.utc))
379
+ updated_at: Optional[datetime.datetime] = None
380
+ resolved_at: Optional[datetime.datetime] = None
381
+ resolution_confidence: Optional[float] = None
382
+ resolution_reasoning: Optional[str] = None
383
+ handoff_reason: Optional[str] = None
311
384
  complexity: Optional[Dict[str, Any]] = None
312
- context: Optional[str] = None
385
+ agent_context: Optional[Dict[str, Any]] = None
313
386
  is_parent: bool = False
314
387
  is_subtask: bool = False
315
388
  parent_id: Optional[str] = None
316
- updated_at: Optional[datetime.datetime] = None
317
- resolved_at: Optional[datetime.datetime] = None
318
- handoff_reason: Optional[str] = None
389
+
390
+ # Add fields for resource integration
391
+ description: Optional[str] = None
392
+ scheduled_start: Optional[datetime.datetime] = None
393
+ scheduled_end: Optional[datetime.datetime] = None
394
+ required_resources: List[Dict[str, Any]] = []
395
+ resource_assignments: List[Dict[str, Any]] = []
319
396
 
320
397
 
321
398
  class Handoff(BaseModel):
@@ -456,17 +533,22 @@ class PlanStatus(BaseModel):
456
533
 
457
534
 
458
535
  class SubtaskModel(BaseModel):
459
- """Represents a subtask breakdown of a complex task."""
536
+ """Model for a subtask in a complex task breakdown."""
460
537
 
461
538
  id: str = Field(default_factory=lambda: str(uuid.uuid4()))
462
- parent_id: str
539
+ parent_id: Optional[str] = None
463
540
  title: str
464
541
  description: str
465
- assignee: Optional[str] = None
466
- status: TicketStatus = TicketStatus.PLANNING
467
- sequence: int
468
- dependencies: List[str] = Field(default_factory=list)
469
- estimated_minutes: int = 30
542
+ estimated_minutes: int
543
+ dependencies: List[str] = []
544
+ status: str = "pending"
545
+ priority: Optional[int] = None
546
+ assigned_to: Optional[str] = None
547
+ scheduled_start: Optional[datetime.datetime] = None
548
+ specialization_tags: List[str] = []
549
+ sequence: int = 0 # Added missing sequence field
550
+ required_resources: List[Dict[str, Any]] = []
551
+ resource_assignments: List[Dict[str, Any]] = []
470
552
 
471
553
 
472
554
  class WorkCapacity(BaseModel):
@@ -483,10 +565,117 @@ class WorkCapacity(BaseModel):
483
565
  )
484
566
 
485
567
 
568
+ class ResourceType(str, Enum):
569
+ """Types of resources that can be booked."""
570
+ ROOM = "room"
571
+ VEHICLE = "vehicle"
572
+ EQUIPMENT = "equipment"
573
+ SEAT = "seat"
574
+ DESK = "desk"
575
+ OTHER = "other"
576
+
577
+
578
+ class ResourceStatus(str, Enum):
579
+ """Status of a resource."""
580
+ AVAILABLE = "available"
581
+ IN_USE = "in_use"
582
+ MAINTENANCE = "maintenance"
583
+ UNAVAILABLE = "unavailable"
584
+
585
+
586
+ class ResourceLocation(BaseModel):
587
+ """Physical location of a resource."""
588
+ address: Optional[str] = None
589
+ building: Optional[str] = None
590
+ floor: Optional[int] = None
591
+ room: Optional[str] = None
592
+ coordinates: Optional[Dict[str, float]] = None # Lat/Long if applicable
593
+
594
+
595
+ class TimeWindow(BaseModel):
596
+ """Time window model for availability and exceptions."""
597
+ start: datetime.datetime
598
+ end: datetime.datetime
599
+
600
+ def overlaps_with(self, other: 'TimeWindow') -> bool:
601
+ """Check if this window overlaps with another one."""
602
+ return self.start < other.end and self.end > other.start
603
+
604
+
605
+ class ResourceAvailabilityWindow(BaseModel):
606
+ """Availability window for a resource with recurring pattern options."""
607
+ day_of_week: Optional[List[int]] = None # 0 = Monday, 6 = Sunday
608
+ start_time: str # Format: "HH:MM", 24h format
609
+ end_time: str # Format: "HH:MM", 24h format
610
+ timezone: str = "UTC"
611
+
612
+
613
+ class Resource(BaseModel):
614
+ """Model for a bookable resource."""
615
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
616
+ name: str
617
+ description: Optional[str] = None
618
+ resource_type: ResourceType
619
+ status: ResourceStatus = ResourceStatus.AVAILABLE
620
+ location: Optional[ResourceLocation] = None
621
+ capacity: Optional[int] = None # For rooms/vehicles
622
+ tags: List[str] = []
623
+ attributes: Dict[str, str] = {} # Custom attributes
624
+ availability_schedule: List[ResourceAvailabilityWindow] = []
625
+ # Overrides for maintenance, holidays
626
+ availability_exceptions: List[TimeWindow] = []
627
+ created_at: datetime.datetime = Field(
628
+ default_factory=lambda: datetime.datetime.now(datetime.timezone.utc))
629
+ updated_at: Optional[datetime.datetime] = None
630
+
631
+ def is_available_at(self, time_window: TimeWindow) -> bool:
632
+ """Check if resource is available during the specified time window."""
633
+ # Check if resource is generally available
634
+ if self.status != ResourceStatus.AVAILABLE:
635
+ return False
636
+
637
+ # Check against exceptions (maintenance, holidays)
638
+ for exception in self.availability_exceptions:
639
+ if exception.overlaps_with(time_window):
640
+ return False
641
+
642
+ # Check if the requested time falls within regular availability
643
+ day_of_week = time_window.start.weekday()
644
+ start_time = time_window.start.strftime("%H:%M")
645
+ end_time = time_window.end.strftime("%H:%M")
646
+
647
+ for window in self.availability_schedule:
648
+ if window.day_of_week is None or day_of_week in window.day_of_week:
649
+ if window.start_time <= start_time and window.end_time >= end_time:
650
+ return True
651
+
652
+ # Default available if no schedule defined
653
+ return len(self.availability_schedule) == 0
654
+
655
+
656
+ class ResourceBooking(BaseModel):
657
+ """Model for a resource booking."""
658
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
659
+ resource_id: str
660
+ user_id: str
661
+ title: str
662
+ description: Optional[str] = None
663
+ start_time: datetime.datetime
664
+ end_time: datetime.datetime
665
+ status: str = "confirmed" # confirmed, cancelled, completed
666
+ booking_reference: Optional[str] = None
667
+ payment_status: Optional[str] = None
668
+ payment_amount: Optional[float] = None
669
+ notes: Optional[str] = None
670
+ created_at: datetime.datetime = Field(
671
+ default_factory=lambda: datetime.datetime.now(datetime.timezone.utc))
672
+ updated_at: Optional[datetime.datetime] = None
673
+
486
674
  #############################################
487
675
  # INTERFACES
488
676
  #############################################
489
677
 
678
+
490
679
  class LLMProvider(Protocol):
491
680
  """Interface for language model providers."""
492
681
 
@@ -621,8 +810,13 @@ class MemoryRepository(Protocol):
621
810
  class AgentRegistry(Protocol):
622
811
  """Interface for agent management."""
623
812
 
624
- def register_ai_agent(self, name: str, agent: Any,
625
- specialization: str) -> None: ...
813
+ def register_ai_agent(
814
+ self,
815
+ name: str,
816
+ instructions: str,
817
+ specialization: str,
818
+ model: str = "gpt-4o-mini",
819
+ ) -> None: ...
626
820
 
627
821
  def register_human_agent(
628
822
  self,
@@ -889,6 +1083,7 @@ class OpenAIAdapter:
889
1083
  messages.append({"role": "user", "content": prompt})
890
1084
 
891
1085
  try:
1086
+ # First try the beta parsing API
892
1087
  completion = self.client.beta.chat.completions.parse(
893
1088
  model=kwargs.get("model", self.model),
894
1089
  messages=messages,
@@ -897,7 +1092,25 @@ class OpenAIAdapter:
897
1092
  )
898
1093
  return completion.choices[0].message.parsed
899
1094
  except Exception as e:
900
- print(f"Error parsing structured output: {e}")
1095
+ print(f"Error with beta.parse method: {e}")
1096
+
1097
+ # Fallback to manual parsing with Pydantic
1098
+ try:
1099
+ response = self.client.chat.completions.create(
1100
+ model=kwargs.get("model", self.model),
1101
+ messages=messages,
1102
+ temperature=kwargs.get("temperature", 0.2),
1103
+ response_format={"type": "json_object"},
1104
+ )
1105
+ response_text = response.choices[0].message.content
1106
+
1107
+ if response_text:
1108
+ # Use Pydantic's parse_raw method instead of json.loads
1109
+ return model_class.parse_raw(response_text)
1110
+
1111
+ except Exception as e:
1112
+ print(f"Error parsing structured output with Pydantic: {e}")
1113
+
901
1114
  # Return default instance as fallback
902
1115
  return model_class()
903
1116
 
@@ -1026,6 +1239,217 @@ class PineconeAdapter:
1026
1239
  # IMPLEMENTATIONS - REPOSITORIES
1027
1240
  #############################################
1028
1241
 
1242
+ class ResourceRepository:
1243
+ """Repository for managing resources."""
1244
+
1245
+ def __init__(self, db_provider):
1246
+ """Initialize with database provider."""
1247
+ self.db = db_provider
1248
+ self.resources_collection = "resources"
1249
+ self.bookings_collection = "resource_bookings"
1250
+
1251
+ # Ensure collections exist
1252
+ self.db.create_collection(self.resources_collection)
1253
+ self.db.create_collection(self.bookings_collection)
1254
+
1255
+ # Create indexes
1256
+ self.db.create_index(self.resources_collection, [("resource_type", 1)])
1257
+ self.db.create_index(self.resources_collection, [("status", 1)])
1258
+ self.db.create_index(self.resources_collection, [("tags", 1)])
1259
+
1260
+ self.db.create_index(self.bookings_collection, [("resource_id", 1)])
1261
+ self.db.create_index(self.bookings_collection, [("user_id", 1)])
1262
+ self.db.create_index(self.bookings_collection, [("start_time", 1)])
1263
+ self.db.create_index(self.bookings_collection, [("end_time", 1)])
1264
+ self.db.create_index(self.bookings_collection, [("status", 1)])
1265
+
1266
+ # Resource CRUD operations
1267
+ def create_resource(self, resource: Resource) -> str:
1268
+ """Create a new resource."""
1269
+ resource_dict = resource.model_dump(mode="json")
1270
+ return self.db.insert_one(self.resources_collection, resource_dict)
1271
+
1272
+ def get_resource(self, resource_id: str) -> Optional[Resource]:
1273
+ """Get a resource by ID."""
1274
+ data = self.db.find_one(self.resources_collection, {"id": resource_id})
1275
+ return Resource(**data) if data else None
1276
+
1277
+ def update_resource(self, resource: Resource) -> bool:
1278
+ """Update a resource."""
1279
+ resource.updated_at = datetime.datetime.now(datetime.timezone.utc)
1280
+ resource_dict = resource.model_dump(mode="json")
1281
+ return self.db.update_one(
1282
+ self.resources_collection,
1283
+ {"id": resource.id},
1284
+ {"$set": resource_dict}
1285
+ )
1286
+
1287
+ def delete_resource(self, resource_id: str) -> bool:
1288
+ """Delete a resource."""
1289
+ return self.db.delete_one(self.resources_collection, {"id": resource_id})
1290
+
1291
+ def find_resources(
1292
+ self,
1293
+ query: Dict[str, Any],
1294
+ sort_by: Optional[str] = None,
1295
+ limit: int = 0
1296
+ ) -> List[Resource]:
1297
+ """Find resources matching query."""
1298
+ sort_params = [(sort_by, 1)] if sort_by else [("name", 1)]
1299
+ data = self.db.find(self.resources_collection,
1300
+ query, sort_params, limit)
1301
+ return [Resource(**item) for item in data]
1302
+
1303
+ def find_available_resources(
1304
+ self,
1305
+ resource_type: Optional[str] = None,
1306
+ tags: Optional[List[str]] = None,
1307
+ start_time: Optional[datetime.datetime] = None,
1308
+ end_time: Optional[datetime.datetime] = None,
1309
+ capacity: Optional[int] = None
1310
+ ) -> List[Resource]:
1311
+ """Find available resources matching the criteria."""
1312
+ # Build base query for available resources
1313
+ query = {"status": ResourceStatus.AVAILABLE}
1314
+
1315
+ if resource_type:
1316
+ query["resource_type"] = resource_type
1317
+
1318
+ if tags:
1319
+ query["tags"] = {"$all": tags}
1320
+
1321
+ if capacity:
1322
+ query["capacity"] = {"$gte": capacity}
1323
+
1324
+ # First get resources that match base criteria
1325
+ resources = self.find_resources(query)
1326
+
1327
+ # If no time range specified, return all matching resources
1328
+ if not start_time or not end_time:
1329
+ return resources
1330
+
1331
+ # Filter by time availability (check bookings and exceptions)
1332
+ time_window = TimeWindow(start=start_time, end=end_time)
1333
+
1334
+ # Check each resource's availability
1335
+ available_resources = []
1336
+ for resource in resources:
1337
+ if resource.is_available_at(time_window):
1338
+ # Check existing bookings
1339
+ if not self._has_conflicting_bookings(resource.id, start_time, end_time):
1340
+ available_resources.append(resource)
1341
+
1342
+ return available_resources
1343
+
1344
+ # Booking CRUD operations
1345
+ def create_booking(self, booking: ResourceBooking) -> str:
1346
+ """Create a new booking."""
1347
+ booking_dict = booking.model_dump(mode="json")
1348
+ return self.db.insert_one(self.bookings_collection, booking_dict)
1349
+
1350
+ def get_booking(self, booking_id: str) -> Optional[ResourceBooking]:
1351
+ """Get a booking by ID."""
1352
+ data = self.db.find_one(self.bookings_collection, {"id": booking_id})
1353
+ return ResourceBooking(**data) if data else None
1354
+
1355
+ def update_booking(self, booking: ResourceBooking) -> bool:
1356
+ """Update a booking."""
1357
+ booking.updated_at = datetime.datetime.now(datetime.timezone.utc)
1358
+ booking_dict = booking.model_dump(mode="json")
1359
+ return self.db.update_one(
1360
+ self.bookings_collection,
1361
+ {"id": booking.id},
1362
+ {"$set": booking_dict}
1363
+ )
1364
+
1365
+ def cancel_booking(self, booking_id: str) -> bool:
1366
+ """Cancel a booking."""
1367
+ return self.db.update_one(
1368
+ self.bookings_collection,
1369
+ {"id": booking_id},
1370
+ {
1371
+ "$set": {
1372
+ "status": "cancelled",
1373
+ "updated_at": datetime.datetime.now(datetime.timezone.utc)
1374
+ }
1375
+ }
1376
+ )
1377
+
1378
+ def get_resource_bookings(
1379
+ self,
1380
+ resource_id: str,
1381
+ start_time: Optional[datetime.datetime] = None,
1382
+ end_time: Optional[datetime.datetime] = None,
1383
+ include_cancelled: bool = False
1384
+ ) -> List[ResourceBooking]:
1385
+ """Get all bookings for a resource within a time range."""
1386
+ query = {"resource_id": resource_id}
1387
+
1388
+ if not include_cancelled:
1389
+ query["status"] = {"$ne": "cancelled"}
1390
+
1391
+ if start_time or end_time:
1392
+ time_query = {}
1393
+ if start_time:
1394
+ time_query["$lte"] = end_time
1395
+ if end_time:
1396
+ time_query["$gte"] = start_time
1397
+ if time_query:
1398
+ query["$or"] = [
1399
+ {"start_time": time_query},
1400
+ {"end_time": time_query},
1401
+ {
1402
+ "$and": [
1403
+ {"start_time": {"$lte": start_time}},
1404
+ {"end_time": {"$gte": end_time}}
1405
+ ]
1406
+ }
1407
+ ]
1408
+
1409
+ data = self.db.find(self.bookings_collection,
1410
+ query, sort=[("start_time", 1)])
1411
+ return [ResourceBooking(**item) for item in data]
1412
+
1413
+ def get_user_bookings(
1414
+ self,
1415
+ user_id: str,
1416
+ include_cancelled: bool = False
1417
+ ) -> List[ResourceBooking]:
1418
+ """Get all bookings for a user."""
1419
+ query = {"user_id": user_id}
1420
+
1421
+ if not include_cancelled:
1422
+ query["status"] = {"$ne": "cancelled"}
1423
+
1424
+ data = self.db.find(self.bookings_collection,
1425
+ query, sort=[("start_time", 1)])
1426
+ return [ResourceBooking(**item) for item in data]
1427
+
1428
+ def _has_conflicting_bookings(
1429
+ self,
1430
+ resource_id: str,
1431
+ start_time: datetime.datetime,
1432
+ end_time: datetime.datetime
1433
+ ) -> bool:
1434
+ """Check if there are any conflicting bookings."""
1435
+ query = {
1436
+ "resource_id": resource_id,
1437
+ "status": {"$ne": "cancelled"},
1438
+ "$or": [
1439
+ {"start_time": {"$lt": end_time, "$gte": start_time}},
1440
+ {"end_time": {"$gt": start_time, "$lte": end_time}},
1441
+ {
1442
+ "$and": [
1443
+ {"start_time": {"$lte": start_time}},
1444
+ {"end_time": {"$gte": end_time}}
1445
+ ]
1446
+ }
1447
+ ]
1448
+ }
1449
+
1450
+ return self.db.count_documents(self.bookings_collection, query) > 0
1451
+
1452
+
1029
1453
  class MongoMemoryProvider:
1030
1454
  """MongoDB implementation of MemoryProvider."""
1031
1455
 
@@ -1160,16 +1584,26 @@ class MongoAIAgentRegistry:
1160
1584
  }
1161
1585
 
1162
1586
  def delete_agent(self, name: str) -> bool:
1163
- """Delete an AI agent by name."""
1587
+ """Delete an AI agent from the registry."""
1588
+ # First check if agent exists
1164
1589
  if name not in self.ai_agents_cache:
1165
1590
  return False
1166
1591
 
1167
- # Remove from cache
1168
- del self.ai_agents_cache[name]
1592
+ # Delete from database
1593
+ result = self.db.delete_one(
1594
+ self.collection,
1595
+ {"name": name}
1596
+ )
1169
1597
 
1170
- # Remove from database
1171
- self.db.delete_one(self.collection, {"name": name})
1172
- return True
1598
+ # Delete from cache if database deletion was successful
1599
+ if result:
1600
+ if name in self.ai_agents_cache:
1601
+ del self.ai_agents_cache[name]
1602
+ print(f"Agent {name} successfully deleted from MongoDB and cache")
1603
+ return True
1604
+ else:
1605
+ print(f"Failed to delete agent {name} from MongoDB")
1606
+ return False
1173
1607
 
1174
1608
 
1175
1609
  class MongoHumanAgentRegistry:
@@ -1356,13 +1790,13 @@ class MongoTicketRepository:
1356
1790
  """Count tickets matching query."""
1357
1791
  return self.db.count_documents(self.collection, query)
1358
1792
 
1359
- def find_stalled_tickets(self, cutoff_time, statuses):
1793
+ async def find_stalled_tickets(self, cutoff_time, statuses):
1360
1794
  """Find tickets that haven't been updated since the cutoff time."""
1361
1795
  query = {
1362
1796
  "status": {"$in": [status.value if isinstance(status, Enum) else status for status in statuses]},
1363
1797
  "updated_at": {"$lt": cutoff_time}
1364
1798
  }
1365
- tickets = self.db_adapter.find("tickets", query)
1799
+ tickets = self.db.find("tickets", query)
1366
1800
  return [Ticket(**ticket) for ticket in tickets]
1367
1801
 
1368
1802
 
@@ -1947,79 +2381,91 @@ class RoutingService:
1947
2381
  self.router_model = router_model
1948
2382
 
1949
2383
  async def route_query(self, query: str) -> str:
1950
- """Route query to the most appropriate AI agent."""
2384
+ """Route a query to the appropriate agent based on content."""
2385
+ # Get available agents
2386
+ agents = self.agent_registry.get_all_ai_agents()
2387
+ if not agents:
2388
+ return "default_agent" # Fallback to default if no agents
2389
+
2390
+ agent_names = list(agents.keys())
2391
+
2392
+ # Format agent descriptions for prompt
2393
+ agent_descriptions = []
1951
2394
  specializations = self.agent_registry.get_specializations()
1952
- # Get AI-only specializations
1953
- ai_specialists = {
1954
- k: v
1955
- for k, v in specializations.items()
1956
- if k in self.agent_registry.get_all_ai_agents()
1957
- }
1958
2395
 
1959
- # Create routing prompt
2396
+ for name in agent_names:
2397
+ spec = specializations.get(name, "General assistant")
2398
+ agent_descriptions.append(f"- {name}: {spec}")
2399
+
2400
+ agent_info = "\n".join(agent_descriptions)
2401
+
2402
+ # Create prompt for routing
1960
2403
  prompt = f"""
1961
- Analyze this user query and determine the MOST APPROPRIATE AI specialist.
2404
+ You are a router that determines which AI agent should handle a user query.
1962
2405
 
1963
2406
  User query: "{query}"
1964
2407
 
1965
- Available AI specialists:
1966
- {json.dumps(ai_specialists, indent=2)}
2408
+ Available agents:
2409
+ {agent_info}
1967
2410
 
1968
- CRITICAL INSTRUCTIONS:
1969
- 1. Choose specialists based on domain expertise match.
1970
- 2. Return EXACTLY ONE specialist name from the available list.
1971
- 3. Do not invent new specialist names.
2411
+ Select the most appropriate agent based on the query and agent specializations.
2412
+ Respond with ONLY the agent name, nothing else.
1972
2413
  """
1973
2414
 
1974
- # Generate routing decision using structured output
1975
- response = ""
1976
- async for chunk in self.llm_provider.generate_text(
1977
- "router",
1978
- prompt,
1979
- system_prompt="You are a routing system that matches queries to the best specialist.",
1980
- stream=False,
1981
- model=self.router_model,
1982
- temperature=0.2,
1983
- response_format={"type": "json_object"},
1984
- ):
1985
- response += chunk
1986
-
2415
+ response_text = ""
1987
2416
  try:
1988
- data = json.loads(response)
1989
- selected_agent = data.get("selected_agent", "")
2417
+ async for chunk in self.llm_provider.generate_text(
2418
+ "system",
2419
+ prompt,
2420
+ system_prompt="You are a routing system. Only respond with the name of the most appropriate agent.",
2421
+ model=self.router_model,
2422
+ temperature=0.1,
2423
+ ):
2424
+ response_text += chunk
1990
2425
 
1991
- # Fallback to matching if needed
1992
- if selected_agent not in ai_specialists:
1993
- agent_name = self._match_agent_name(
1994
- selected_agent, list(ai_specialists.keys())
1995
- )
1996
- else:
1997
- agent_name = selected_agent
2426
+ # Clean up the response text to handle different formats
2427
+ response_text = response_text.strip()
2428
+
2429
+ # First try to parse as JSON (old behavior)
2430
+ try:
2431
+ data = json.loads(response_text)
2432
+ if isinstance(data, dict) and "agent" in data:
2433
+ return self._match_agent_name(data["agent"], agent_names)
2434
+ except json.JSONDecodeError:
2435
+ # Not JSON, try to parse as plain text
2436
+ pass
2437
+
2438
+ # Treat as plain text - just match the agent name directly
2439
+ return self._match_agent_name(response_text, agent_names)
1998
2440
 
1999
- return agent_name
2000
2441
  except Exception as e:
2001
- print(f"Error parsing routing decision: {e}")
2002
- # Fallback to the old matching method
2003
- return self._match_agent_name(response.strip(), list(ai_specialists.keys()))
2442
+ print(f"Error in routing: {e}")
2443
+ # Default to the first agent if there's an error
2444
+ return agent_names[0]
2004
2445
 
2005
2446
  def _match_agent_name(self, response: str, agent_names: List[str]) -> str:
2006
- """Match router response to an actual AI agent name."""
2007
- # Exact match (priority)
2008
- if response in agent_names:
2009
- return response
2447
+ """Match the response to a valid agent name."""
2448
+ # Clean up the response
2449
+ if isinstance(response, dict) and "name" in response:
2450
+ response = response["name"] # Handle {"name": "agent_name"} format
2451
+
2452
+ # Convert to string and clean it up
2453
+ clean_response = str(response).strip().lower()
2010
2454
 
2011
- # Case-insensitive match
2455
+ # Direct match first
2012
2456
  for name in agent_names:
2013
- if name.lower() == response.lower():
2457
+ if name.lower() == clean_response:
2014
2458
  return name
2015
2459
 
2016
- # Partial match
2460
+ # Check for partial matches
2017
2461
  for name in agent_names:
2018
- if name.lower() in response.lower():
2462
+ if name.lower() in clean_response or clean_response in name.lower():
2019
2463
  return name
2020
2464
 
2021
- # Fallback to first AI agent
2022
- return agent_names[0] if agent_names else "default"
2465
+ # If no match, return first agent as default
2466
+ print(
2467
+ f"No matching agent found for: '{response}'. Using {agent_names[0]}")
2468
+ return agent_names[0]
2023
2469
 
2024
2470
 
2025
2471
  class TicketService:
@@ -2340,21 +2786,14 @@ class CriticService:
2340
2786
  class NotificationService:
2341
2787
  """Service for sending notifications to human agents or users using notification plugins."""
2342
2788
 
2343
- def __init__(self, human_agent_registry: MongoHumanAgentRegistry):
2789
+ def __init__(self, human_agent_registry: MongoHumanAgentRegistry, tool_registry=None):
2344
2790
  """Initialize the notification service with a human agent registry."""
2345
2791
  self.human_agent_registry = human_agent_registry
2792
+ self.tool_registry = tool_registry
2346
2793
 
2347
2794
  def send_notification(self, recipient_id: str, message: str, metadata: Dict[str, Any] = None) -> bool:
2348
2795
  """
2349
2796
  Send a notification to a human agent using configured notification channels or legacy handler.
2350
-
2351
- Args:
2352
- recipient_id: ID of the human agent to notify
2353
- message: Notification message content
2354
- metadata: Additional data related to the notification (e.g., ticket_id)
2355
-
2356
- Returns:
2357
- True if notification was sent, False otherwise
2358
2797
  """
2359
2798
  # Get human agent information
2360
2799
  agent = self.human_agent_registry.get_human_agent(recipient_id)
@@ -2380,6 +2819,11 @@ class NotificationService:
2380
2819
  f"No notification channels configured for agent {recipient_id}")
2381
2820
  return False
2382
2821
 
2822
+ # No tool registry available
2823
+ if not self.tool_registry:
2824
+ print("No tool registry available for notifications")
2825
+ return False
2826
+
2383
2827
  # Try each notification channel until one succeeds
2384
2828
  success = False
2385
2829
  for channel in notification_channels:
@@ -2396,9 +2840,9 @@ class NotificationService:
2396
2840
  if metadata:
2397
2841
  tool_params["metadata"] = metadata
2398
2842
 
2399
- result = tool_registry.get_tool(f"notify_{channel_type}")
2400
- if result:
2401
- response = result.execute(**tool_params)
2843
+ tool = self.tool_registry.get_tool(f"notify_{channel_type}")
2844
+ if tool:
2845
+ response = tool.execute(**tool_params)
2402
2846
  if response.get("status") == "success":
2403
2847
  success = True
2404
2848
  break
@@ -2431,11 +2875,15 @@ class AgentService:
2431
2875
  human_agent_registry: Optional[MongoHumanAgentRegistry] = None,
2432
2876
  ai_agent_registry: Optional[MongoAIAgentRegistry] = None,
2433
2877
  organization_mission: Optional[OrganizationMission] = None,
2878
+ config: Optional[Dict[str, Any]] = None
2434
2879
  ):
2880
+ """Initialize the agent service with LLM provider and optional registries."""
2435
2881
  self.llm_provider = llm_provider
2436
2882
  self.human_agent_registry = human_agent_registry
2437
2883
  self.ai_agent_registry = ai_agent_registry
2438
2884
  self.organization_mission = organization_mission
2885
+ self.config = config or {}
2886
+ self._last_handoff = None
2439
2887
 
2440
2888
  # For backward compatibility
2441
2889
  self.ai_agents = {}
@@ -2444,9 +2892,22 @@ class AgentService:
2444
2892
 
2445
2893
  self.specializations = {}
2446
2894
 
2447
- # Initialize plugin system
2448
- self.plugin_manager = PluginManager()
2449
- self.plugin_manager.load_all_plugins()
2895
+ # Create our tool registry and plugin manager
2896
+ self.tool_registry = ToolRegistry()
2897
+ self.plugin_manager = PluginManager(
2898
+ config=self.config, tool_registry=self.tool_registry)
2899
+
2900
+ # Load plugins
2901
+ loaded_count = self.plugin_manager.load_all_plugins()
2902
+ print(
2903
+ f"Loaded {loaded_count} plugins with {len(self.tool_registry.list_all_tools())} registered tools")
2904
+
2905
+ # Configure all tools with our config after loading
2906
+ self.tool_registry.configure_all_tools(self.config)
2907
+
2908
+ # Debug output of registered tools
2909
+ print(
2910
+ f"Available tools after initialization: {self.tool_registry.list_all_tools()}")
2450
2911
 
2451
2912
  # If human agent registry is provided, initialize specializations from it
2452
2913
  if self.human_agent_registry:
@@ -2462,6 +2923,205 @@ class AgentService:
2462
2923
  if not self.human_agent_registry:
2463
2924
  self.human_agents = {}
2464
2925
 
2926
+ def get_all_ai_agents(self) -> Dict[str, Any]:
2927
+ """Get all registered AI agents."""
2928
+ return self.ai_agents
2929
+
2930
+ def get_agent_tools(self, agent_name: str) -> List[Dict[str, Any]]:
2931
+ """Get all tools available to a specific agent."""
2932
+ return self.tool_registry.get_agent_tools(agent_name)
2933
+
2934
+ def register_tool_for_agent(self, agent_name: str, tool_name: str) -> None:
2935
+ """Give an agent access to a specific tool."""
2936
+ # Make sure the tool exists
2937
+ if tool_name not in self.tool_registry.list_all_tools():
2938
+ print(
2939
+ f"Error registering tool {tool_name} for agent {agent_name}: Tool not registered")
2940
+ raise ValueError(f"Tool {tool_name} is not registered")
2941
+
2942
+ # Check if agent exists
2943
+ if agent_name not in self.ai_agents and (
2944
+ not self.ai_agent_registry or
2945
+ not self.ai_agent_registry.get_ai_agent(agent_name)
2946
+ ):
2947
+ print(
2948
+ f"Warning: Agent {agent_name} not found but attempting to register tool")
2949
+
2950
+ # Assign the tool to the agent
2951
+ success = self.tool_registry.assign_tool_to_agent(
2952
+ agent_name, tool_name)
2953
+
2954
+ if success:
2955
+ print(
2956
+ f"Successfully registered tool {tool_name} for agent {agent_name}")
2957
+ else:
2958
+ print(
2959
+ f"Failed to register tool {tool_name} for agent {agent_name}")
2960
+
2961
+ def process_json_response(self, response_text: str, agent_name: str) -> str:
2962
+ """Process a complete response to handle any JSON handoffs or tool calls."""
2963
+ # Check if the response is a JSON object for handoff or tool call
2964
+ if response_text.strip().startswith('{') and ('"handoff":' in response_text or '"tool_call":' in response_text):
2965
+ try:
2966
+ data = json.loads(response_text.strip())
2967
+
2968
+ # Handle handoff
2969
+ if "handoff" in data:
2970
+ target_agent = data["handoff"].get(
2971
+ "target_agent", "another agent")
2972
+ reason = data["handoff"].get(
2973
+ "reason", "to better assist with your request")
2974
+ return f"I'll connect you with {target_agent} who can better assist with your request. Reason: {reason}"
2975
+
2976
+ # Handle tool call
2977
+ if "tool_call" in data:
2978
+ tool_data = data["tool_call"]
2979
+ tool_name = tool_data.get("name")
2980
+ parameters = tool_data.get("parameters", {})
2981
+
2982
+ if tool_name:
2983
+ try:
2984
+ # Execute the tool
2985
+ tool_result = self.execute_tool(
2986
+ agent_name, tool_name, parameters)
2987
+
2988
+ # Format the result
2989
+ if tool_result.get("status") == "success":
2990
+ return f"I searched for information and found:\n\n{tool_result.get('result', '')}"
2991
+ else:
2992
+ return f"I tried to search for information, but encountered an error: {tool_result.get('message', 'Unknown error')}"
2993
+ except Exception as e:
2994
+ return f"I tried to use {tool_name}, but encountered an error: {str(e)}"
2995
+ except json.JSONDecodeError:
2996
+ # Not valid JSON
2997
+ pass
2998
+
2999
+ # Return original if not JSON or if processing fails
3000
+ return response_text
3001
+
3002
+ def get_agent_system_prompt(self, agent_name: str) -> str:
3003
+ """Get the system prompt for an agent, including tool instructions if available."""
3004
+ # Get the agent's base instructions
3005
+ if agent_name not in self.ai_agents:
3006
+ raise ValueError(f"Agent {agent_name} not found")
3007
+
3008
+ agent_config = self.ai_agents[agent_name]
3009
+ instructions = agent_config.get("instructions", "")
3010
+
3011
+ # Add tool instructions if any tools are available
3012
+ available_tools = self.get_agent_tools(agent_name)
3013
+ if available_tools:
3014
+ tools_json = json.dumps(available_tools, indent=2)
3015
+
3016
+ # Tool instructions using JSON format similar to handoffs
3017
+ tool_instructions = f"""
3018
+ You have access to the following tools:
3019
+ {tools_json}
3020
+
3021
+ IMPORTANT - TOOL USAGE: When you need to use a tool, respond with a JSON object using this format:
3022
+
3023
+ {{
3024
+ "tool_call": {{
3025
+ "name": "tool_name",
3026
+ "parameters": {{
3027
+ "param1": "value1",
3028
+ "param2": "value2"
3029
+ }}
3030
+ }}
3031
+ }}
3032
+
3033
+ Example: To search the internet for "latest Solana news", respond with:
3034
+
3035
+ {{
3036
+ "tool_call": {{
3037
+ "name": "search_internet",
3038
+ "parameters": {{
3039
+ "query": "latest Solana news"
3040
+ }}
3041
+ }}
3042
+ }}
3043
+
3044
+ ALWAYS use the search_internet tool when the user asks for current information or facts that might be beyond your knowledge cutoff. DO NOT attempt to handoff for information that could be obtained using search_internet.
3045
+ """
3046
+ instructions = f"{instructions}\n\n{tool_instructions}"
3047
+
3048
+ # Add specific instructions about valid handoff agents
3049
+ valid_agents = list(self.ai_agents.keys())
3050
+ if valid_agents:
3051
+ handoff_instructions = f"""
3052
+ IMPORTANT - HANDOFFS: You can ONLY hand off to these existing agents: {', '.join(valid_agents)}
3053
+ DO NOT invent or reference agents that don't exist in this list.
3054
+
3055
+ To hand off to another agent, use this format:
3056
+ {{"handoff": {{"target_agent": "<AGENT_NAME_FROM_LIST_ABOVE>", "reason": "detailed reason for handoff"}}}}
3057
+ """
3058
+ instructions = f"{instructions}\n\n{handoff_instructions}"
3059
+
3060
+ return instructions
3061
+
3062
+ def process_tool_calls(self, agent_name: str, response_text: str) -> str:
3063
+ """Process any tool calls in the agent's response and return updated response."""
3064
+ # Regex to find tool calls in the format TOOL_START {...} TOOL_END
3065
+ tool_pattern = r"TOOL_START\s*([\s\S]*?)\s*TOOL_END"
3066
+ tool_matches = re.findall(tool_pattern, response_text)
3067
+
3068
+ if not tool_matches:
3069
+ return response_text
3070
+
3071
+ print(
3072
+ f"Found {len(tool_matches)} tool calls in response from {agent_name}")
3073
+
3074
+ # Process each tool call
3075
+ modified_response = response_text
3076
+ for tool_json in tool_matches:
3077
+ try:
3078
+ # Parse the tool call JSON
3079
+ tool_call_text = tool_json.strip()
3080
+ print(f"Processing tool call: {tool_call_text[:100]}")
3081
+
3082
+ # Parse the JSON (handle both normal and stringified JSON)
3083
+ try:
3084
+ tool_call = json.loads(tool_call_text)
3085
+ except json.JSONDecodeError as e:
3086
+ # If there are escaped quotes or formatting issues, try cleaning it up
3087
+ cleaned_json = tool_call_text.replace(
3088
+ '\\"', '"').replace('\\n', '\n')
3089
+ tool_call = json.loads(cleaned_json)
3090
+
3091
+ tool_name = tool_call.get("name")
3092
+ parameters = tool_call.get("parameters", {})
3093
+
3094
+ if tool_name:
3095
+ # Execute the tool
3096
+ print(
3097
+ f"Executing tool {tool_name} with parameters: {parameters}")
3098
+ tool_result = self.execute_tool(
3099
+ agent_name, tool_name, parameters)
3100
+
3101
+ # Format the result for inclusion in the response
3102
+ if tool_result.get("status") == "success":
3103
+ formatted_result = f"\n\nI searched for information and found:\n\n{tool_result.get('result', '')}"
3104
+ else:
3105
+ formatted_result = f"\n\nI tried to search for information, but encountered an error: {tool_result.get('message', 'Unknown error')}"
3106
+
3107
+ # Replace the entire tool block with the result
3108
+ full_tool_block = f"TOOL_START\n{tool_json}\nTOOL_END"
3109
+ modified_response = modified_response.replace(
3110
+ full_tool_block, formatted_result)
3111
+ print(f"Successfully processed tool call: {tool_name}")
3112
+ except Exception as e:
3113
+ print(f"Error processing tool call: {str(e)}")
3114
+ # Replace with error message
3115
+ full_tool_block = f"TOOL_START\n{tool_json}\nTOOL_END"
3116
+ modified_response = modified_response.replace(
3117
+ full_tool_block, "\n\nI tried to search for information, but encountered an error processing the tool call.")
3118
+
3119
+ return modified_response
3120
+
3121
+ def get_agent_tools(self, agent_name: str) -> List[Dict[str, Any]]:
3122
+ """Get all tools available to a specific agent."""
3123
+ return self.tool_registry.get_agent_tools(agent_name)
3124
+
2465
3125
  def register_ai_agent(
2466
3126
  self,
2467
3127
  name: str,
@@ -2487,7 +3147,7 @@ class AgentService:
2487
3147
  # Use registry if available
2488
3148
  if self.ai_agent_registry:
2489
3149
  self.ai_agent_registry.register_ai_agent(
2490
- name, full_instructions, specialization, model
3150
+ name, full_instructions, specialization, model,
2491
3151
  )
2492
3152
  # Update local cache for backward compatibility
2493
3153
  self.ai_agents = self.ai_agent_registry.get_all_ai_agents()
@@ -2507,32 +3167,37 @@ class AgentService:
2507
3167
  return merged
2508
3168
  return self.specializations
2509
3169
 
2510
- def register_tool_for_agent(self, agent_name: str, tool_name: str) -> None:
2511
- """Give an agent access to a specific tool."""
2512
- if agent_name not in self.ai_agents:
2513
- raise ValueError(f"Agent {agent_name} not found")
2514
-
2515
- tool_registry.assign_tool_to_agent(agent_name, tool_name)
3170
+ def execute_tool(self, agent_name: str, tool_name: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
3171
+ """Execute a tool on behalf of an agent."""
3172
+ print(f"Executing tool {tool_name} for agent {agent_name}")
3173
+ print(f"Parameters: {parameters}")
2516
3174
 
2517
- def get_agent_tools(self, agent_name: str) -> List[Dict[str, Any]]:
2518
- """Get all tools available to a specific agent."""
2519
- return tool_registry.get_agent_tools(agent_name)
3175
+ # Get the tool directly from the registry
3176
+ tool = self.tool_registry.get_tool(tool_name)
3177
+ if not tool:
3178
+ print(
3179
+ f"Tool {tool_name} not found in registry. Available tools: {self.tool_registry.list_all_tools()}")
3180
+ return {"status": "error", "message": f"Tool {tool_name} not found"}
2520
3181
 
2521
- def execute_tool(
2522
- self, agent_name: str, tool_name: str, parameters: Dict[str, Any]
2523
- ) -> Dict[str, Any]:
2524
- """Execute a tool on behalf of an agent."""
2525
- # Check if agent has access to this tool
2526
- agent_tools = tool_registry.get_agent_tools(agent_name)
2527
- tool_names = [tool["name"] for tool in agent_tools]
3182
+ # Check if agent has access
3183
+ agent_tools = self.get_agent_tools(agent_name)
3184
+ tool_names = [t["name"] for t in agent_tools]
2528
3185
 
2529
3186
  if tool_name not in tool_names:
2530
- raise ValueError(
2531
- f"Agent {agent_name} does not have access to tool {tool_name}"
2532
- )
3187
+ print(
3188
+ f"Agent {agent_name} does not have access to tool {tool_name}. Available tools: {tool_names}")
3189
+ return {"status": "error", "message": f"Agent {agent_name} does not have access to tool {tool_name}"}
2533
3190
 
2534
- # Execute the tool
2535
- return self.plugin_manager.execute_tool(tool_name, **parameters)
3191
+ # Execute the tool with parameters
3192
+ try:
3193
+ print(
3194
+ f"Executing {tool_name} with config: {'API key present' if hasattr(tool, '_api_key') and tool._api_key else 'No API key'}")
3195
+ result = tool.execute(**parameters)
3196
+ print(f"Tool execution result: {result.get('status', 'unknown')}")
3197
+ return result
3198
+ except Exception as e:
3199
+ print(f"Error executing tool {tool_name}: {str(e)}")
3200
+ return {"status": "error", "message": f"Error: {str(e)}"}
2536
3201
 
2537
3202
  def register_human_agent(
2538
3203
  self,
@@ -2589,69 +3254,246 @@ class AgentService:
2589
3254
 
2590
3255
  agent_config = self.ai_agents[agent_name]
2591
3256
 
2592
- # Get instructions and add memory context
2593
- instructions = agent_config["instructions"]
3257
+ # Get the properly formatted system prompt with tools and handoff instructions
3258
+ instructions = self.get_agent_system_prompt(agent_name)
3259
+
3260
+ # Add memory context
2594
3261
  if memory_context:
2595
3262
  instructions += f"\n\nUser context and history:\n{memory_context}"
2596
3263
 
2597
- # Add tool information if agent has any tools
2598
- tools = self.get_agent_tools(agent_name)
2599
- if tools and "tools" not in kwargs:
2600
- kwargs["tools"] = tools
2601
-
2602
- # Add specific instruction for simple queries to prevent handoff format leakage
2603
- if len(query.strip()) < 10:
2604
- instructions += "\n\nIMPORTANT: If the user sends a simple test message, respond conversationally. Never output raw JSON handoff objects directly to the user."
3264
+ # Add critical instruction to prevent raw JSON
3265
+ instructions += "\n\nCRITICAL: When using tools or making handoffs, ALWAYS respond with properly formatted JSON as instructed."
2605
3266
 
2606
3267
  # Generate response
2607
- response_text = ""
2608
- async for chunk in self.llm_provider.generate_text(
2609
- user_id=user_id,
2610
- prompt=query,
2611
- system_prompt=instructions,
2612
- model=agent_config["model"],
2613
- **kwargs,
2614
- ):
2615
- # Filter out raw handoff JSON before yielding to user
2616
- if not response_text and chunk.strip().startswith('{"handoff":'):
2617
- # If we're starting with a handoff JSON, replace with a proper response
2618
- yield "Hello! I'm here to help. What can I assist you with today?"
2619
- response_text += chunk # Still store it for processing later
2620
- else:
2621
- yield chunk
2622
- response_text += chunk
3268
+ tool_json_found = False
3269
+ full_response = ""
2623
3270
 
2624
- # Process handoffs after yielding response (unchanged code)
2625
3271
  try:
2626
- response_data = json.loads(response_text)
2627
- if "tool_calls" in response_data:
2628
- for tool_call in response_data["tool_calls"]:
2629
- # Extract tool name and arguments
2630
- if isinstance(tool_call, dict):
2631
- # Direct format
2632
- tool_name = tool_call.get("name")
2633
- params = tool_call.get("parameters", {})
2634
-
2635
- # For the updated OpenAI format
2636
- if "function" in tool_call:
2637
- function_data = tool_call["function"]
2638
- tool_name = function_data.get("name")
2639
- try:
2640
- params = json.loads(
2641
- function_data.get("arguments", "{}"))
2642
- except Exception:
2643
- params = {}
3272
+ async for chunk in self.llm_provider.generate_text(
3273
+ user_id=user_id,
3274
+ prompt=query,
3275
+ system_prompt=instructions,
3276
+ model=agent_config["model"],
3277
+ **kwargs,
3278
+ ):
3279
+ # Add to full response
3280
+ full_response += chunk
3281
+
3282
+ # Check if this might be JSON
3283
+ if full_response.strip().startswith("{") and not tool_json_found:
3284
+ tool_json_found = True
3285
+ print(
3286
+ f"Detected potential JSON response starting with: {full_response[:50]}...")
3287
+ continue
2644
3288
 
2645
- # Execute the tool
2646
- if tool_name:
2647
- self.execute_tool(agent_name, tool_name, params)
2648
- except Exception:
2649
- # If it's not JSON or doesn't have tool_calls, we've already yielded the response
2650
- pass
3289
+ # If not JSON, yield the chunk
3290
+ if not tool_json_found:
3291
+ yield chunk
2651
3292
 
2652
- def get_all_ai_agents(self) -> Dict[str, Any]:
2653
- """Get all registered AI agents."""
2654
- return self.ai_agents
3293
+ # Process JSON if found
3294
+ if tool_json_found:
3295
+ try:
3296
+ print(
3297
+ f"Processing JSON response: {full_response[:100]}...")
3298
+ data = json.loads(full_response.strip())
3299
+ print(
3300
+ f"Successfully parsed JSON with keys: {list(data.keys())}")
3301
+
3302
+ # Handle tool call
3303
+ if "tool_call" in data:
3304
+ tool_data = data["tool_call"]
3305
+ tool_name = tool_data.get("name")
3306
+ parameters = tool_data.get("parameters", {})
3307
+
3308
+ print(
3309
+ f"Processing tool call: {tool_name} with parameters: {parameters}")
3310
+
3311
+ if tool_name:
3312
+ try:
3313
+ # Execute tool
3314
+ print(f"Executing tool: {tool_name}")
3315
+ tool_result = self.execute_tool(
3316
+ agent_name, tool_name, parameters)
3317
+ print(
3318
+ f"Tool execution result status: {tool_result.get('status')}")
3319
+
3320
+ if tool_result.get("status") == "success":
3321
+ print(
3322
+ f"Tool executed successfully - yielding result")
3323
+ yield tool_result.get('result', '')
3324
+ else:
3325
+ print(
3326
+ f"Tool execution failed: {tool_result.get('message')}")
3327
+ yield f"Error: {tool_result.get('message', 'Unknown error')}"
3328
+ except Exception as e:
3329
+ print(f"Tool execution exception: {str(e)}")
3330
+ yield f"Error executing tool: {str(e)}"
3331
+
3332
+ # Handle handoff
3333
+ elif "handoff" in data:
3334
+ print(
3335
+ f"Processing handoff to: {data['handoff'].get('target_agent')}")
3336
+ # Store handoff data but don't yield anything
3337
+ self._last_handoff = data["handoff"]
3338
+ return
3339
+
3340
+ # If we got JSON but it's not a tool call or handoff, yield it as text
3341
+ else:
3342
+ print(
3343
+ f"Received JSON but not a tool call or handoff. Keys: {list(data.keys())}")
3344
+ yield full_response
3345
+
3346
+ except json.JSONDecodeError as e:
3347
+ # Not valid JSON, yield it as is
3348
+ print(f"JSON parse error: {str(e)} - yielding as text")
3349
+ yield full_response
3350
+
3351
+ # If nothing has been yielded yet (e.g., failed JSON parsing), yield the full response
3352
+ if not tool_json_found:
3353
+ print(f"Non-JSON response handled normally")
3354
+
3355
+ except Exception as e:
3356
+ print(f"Error in generate_response: {str(e)}")
3357
+ import traceback
3358
+ print(traceback.format_exc())
3359
+ yield f"I'm sorry, I encountered an error: {str(e)}"
3360
+
3361
+
3362
+ class ResourceService:
3363
+ """Service for managing resources and bookings."""
3364
+
3365
+ def __init__(self, resource_repository: ResourceRepository):
3366
+ """Initialize with resource repository."""
3367
+ self.repository = resource_repository
3368
+
3369
+ async def create_resource(self, resource_data, resource_type):
3370
+ """Create a new resource from dictionary data."""
3371
+ # Generate UUID for ID since it can't be None
3372
+ resource_id = str(uuid.uuid4())
3373
+
3374
+ resource = Resource(
3375
+ id=resource_id,
3376
+ name=resource_data["name"],
3377
+ resource_type=resource_type,
3378
+ description=resource_data.get("description"),
3379
+ location=resource_data.get("location"),
3380
+ capacity=resource_data.get("capacity"),
3381
+ tags=resource_data.get("tags", []),
3382
+ attributes=resource_data.get("attributes", {}),
3383
+ availability_schedule=resource_data.get(
3384
+ "availability_schedule", [])
3385
+ )
3386
+
3387
+ # Don't use await when calling repository methods
3388
+ return self.repository.create_resource(resource)
3389
+
3390
+ async def get_resource(self, resource_id):
3391
+ """Get a resource by ID."""
3392
+ # Don't use await
3393
+ return self.repository.get_resource(resource_id)
3394
+
3395
+ async def update_resource(self, resource_id, updates):
3396
+ """Update a resource."""
3397
+ resource = self.repository.get_resource(resource_id)
3398
+ if not resource:
3399
+ return False
3400
+
3401
+ # Apply updates
3402
+ for key, value in updates.items():
3403
+ if hasattr(resource, key):
3404
+ setattr(resource, key, value)
3405
+
3406
+ # Don't use await
3407
+ return self.repository.update_resource(resource)
3408
+
3409
+ async def list_resources(self, resource_type=None):
3410
+ """List all resources, optionally filtered by type."""
3411
+ # Don't use await
3412
+ return self.repository.list_resources(resource_type)
3413
+
3414
+ async def find_available_resources(self, start_time, end_time, capacity=None, tags=None, resource_type=None):
3415
+ """Find available resources for a time period."""
3416
+ # Don't use await
3417
+ resources = self.repository.find_resources(
3418
+ resource_type, capacity, tags)
3419
+
3420
+ # Filter by availability
3421
+ available = []
3422
+ for resource in resources:
3423
+ time_window = TimeWindow(start=start_time, end=end_time)
3424
+ if resource.is_available_at(time_window):
3425
+ if not self.repository._has_conflicting_bookings(resource.id, start_time, end_time):
3426
+ available.append(resource)
3427
+
3428
+ return available
3429
+
3430
+ async def create_booking(self, resource_id, user_id, title, start_time, end_time, description=None, notes=None):
3431
+ """Create a booking for a resource."""
3432
+ # Check if resource exists
3433
+ resource = self.repository.get_resource(resource_id)
3434
+ if not resource:
3435
+ return False, None, "Resource not found"
3436
+
3437
+ # Check for conflicts
3438
+ if self.repository._has_conflicting_bookings(resource_id, start_time, end_time):
3439
+ return False, None, "Resource is already booked during the requested time"
3440
+
3441
+ # Create booking
3442
+ booking_data = ResourceBooking(
3443
+ id=str(uuid.uuid4()),
3444
+ resource_id=resource_id,
3445
+ user_id=user_id,
3446
+ title=title,
3447
+ description=description,
3448
+ status="confirmed",
3449
+ start_time=start_time,
3450
+ end_time=end_time,
3451
+ notes=notes,
3452
+ created_at=datetime.datetime.now(datetime.timezone.utc)
3453
+ )
3454
+
3455
+ booking_id = self.repository.create_booking(booking_data)
3456
+
3457
+ # Return (success, booking_id, error)
3458
+ return True, booking_id, None
3459
+
3460
+ async def cancel_booking(self, booking_id, user_id):
3461
+ """Cancel a booking."""
3462
+ # Verify booking exists
3463
+ booking = self.repository.get_booking(booking_id)
3464
+ if not booking:
3465
+ return False, "Booking not found"
3466
+
3467
+ # Verify user owns the booking
3468
+ if booking.user_id != user_id:
3469
+ return False, "Not authorized to cancel this booking"
3470
+
3471
+ # Cancel booking
3472
+ result = self.repository.cancel_booking(booking_id)
3473
+ if result:
3474
+ return True, None
3475
+ return False, "Failed to cancel booking"
3476
+
3477
+ async def get_resource_schedule(self, resource_id, start_date, end_date):
3478
+ """Get a resource's schedule for a date range."""
3479
+ return self.repository.get_resource_schedule(resource_id, start_date, end_date)
3480
+
3481
+ async def get_user_bookings(self, user_id, include_cancelled=False):
3482
+ """Get all bookings for a user with resource details."""
3483
+ bookings = self.repository.get_user_bookings(
3484
+ user_id,
3485
+ include_cancelled
3486
+ )
3487
+
3488
+ result = []
3489
+ for booking in bookings:
3490
+ resource = self.repository.get_resource(booking.resource_id)
3491
+ result.append({
3492
+ "booking": booking.model_dump(),
3493
+ "resource": resource.model_dump() if resource else None
3494
+ })
3495
+
3496
+ return result
2655
3497
 
2656
3498
 
2657
3499
  class TaskPlanningService:
@@ -3008,6 +3850,181 @@ class TaskPlanningService:
3008
3850
  "domain_knowledge": 5,
3009
3851
  }
3010
3852
 
3853
+ async def generate_subtasks_with_resources(
3854
+ self, ticket_id: str, task_description: str
3855
+ ) -> List[SubtaskModel]:
3856
+ """Generate subtasks for a complex task with resource requirements."""
3857
+ # Fetch ticket to verify it exists
3858
+ ticket = self.ticket_repository.get_by_id(ticket_id)
3859
+ if not ticket:
3860
+ raise ValueError(f"Ticket {ticket_id} not found")
3861
+
3862
+ # Mark parent ticket as a parent
3863
+ self.ticket_repository.update(
3864
+ ticket_id, {"is_parent": True, "status": TicketStatus.PLANNING}
3865
+ )
3866
+
3867
+ # Generate subtasks using LLM
3868
+ agent_name = next(iter(self.agent_service.get_all_ai_agents().keys()))
3869
+ agent_config = self.agent_service.get_all_ai_agents()[agent_name]
3870
+ model = agent_config.get("model", "gpt-4o-mini")
3871
+
3872
+ prompt = f"""
3873
+ Break down the following complex task into logical subtasks with resource requirements:
3874
+
3875
+ TASK: {task_description}
3876
+
3877
+ For each subtask, provide:
3878
+ 1. A brief title
3879
+ 2. A clear description of what needs to be done
3880
+ 3. An estimate of time required in minutes
3881
+ 4. Any dependencies (which subtasks must be completed first)
3882
+ 5. Required resources with these details:
3883
+ - Resource type (room, equipment, etc.)
3884
+ - Quantity needed
3885
+ - Specific requirements (e.g., "room with projector", "laptop with design software")
3886
+
3887
+ Format as a JSON array of objects with these fields:
3888
+ - title: string
3889
+ - description: string
3890
+ - estimated_minutes: number
3891
+ - dependencies: array of previous subtask titles that must be completed first
3892
+ - required_resources: array of objects with fields:
3893
+ - resource_type: string
3894
+ - quantity: number
3895
+ - requirements: string (specific features needed)
3896
+
3897
+ The subtasks should be in a logical sequence. Keep dependencies minimal and avoid circular dependencies.
3898
+ """
3899
+
3900
+ response_text = ""
3901
+ async for chunk in self.llm_provider.generate_text(
3902
+ ticket.user_id,
3903
+ prompt,
3904
+ system_prompt="You are an expert project planner who breaks down complex tasks efficiently and identifies required resources.",
3905
+ stream=False,
3906
+ model=model,
3907
+ temperature=0.2,
3908
+ response_format={"type": "json_object"},
3909
+ ):
3910
+ response_text += chunk
3911
+
3912
+ try:
3913
+ data = json.loads(response_text)
3914
+ subtasks_data = data.get("subtasks", [])
3915
+
3916
+ # Create subtask objects
3917
+ subtasks = []
3918
+ for i, task_data in enumerate(subtasks_data):
3919
+ subtask = SubtaskModel(
3920
+ parent_id=ticket_id,
3921
+ title=task_data.get("title", f"Subtask {i+1}"),
3922
+ description=task_data.get("description", ""),
3923
+ estimated_minutes=task_data.get("estimated_minutes", 30),
3924
+ dependencies=[], # We'll fill this after all subtasks are created
3925
+ status="planning",
3926
+ required_resources=task_data.get("required_resources", []),
3927
+ is_subtask=True,
3928
+ created_at=datetime.datetime.now(datetime.timezone.utc)
3929
+ )
3930
+ subtasks.append(subtask)
3931
+
3932
+ # Process dependencies (convert title references to IDs)
3933
+ title_to_id = {task.title: task.id for task in subtasks}
3934
+ for i, task_data in enumerate(subtasks_data):
3935
+ dependency_titles = task_data.get("dependencies", [])
3936
+ for title in dependency_titles:
3937
+ if title in title_to_id:
3938
+ subtasks[i].dependencies.append(title_to_id[title])
3939
+
3940
+ # Store subtasks in database
3941
+ for subtask in subtasks:
3942
+ self.ticket_repository.create(subtask)
3943
+
3944
+ return subtasks
3945
+
3946
+ except Exception as e:
3947
+ print(f"Error generating subtasks with resources: {e}")
3948
+ return []
3949
+
3950
+ async def allocate_resources(
3951
+ self, subtask_id: str, resource_service: ResourceService
3952
+ ) -> Tuple[bool, str]:
3953
+ """Allocate resources to a subtask."""
3954
+ # Get the subtask
3955
+ subtask = self.ticket_repository.get_by_id(subtask_id)
3956
+ if not subtask or not subtask.is_subtask:
3957
+ return False, "Subtask not found"
3958
+
3959
+ if not subtask.required_resources:
3960
+ return True, "No resources required"
3961
+
3962
+ if not subtask.scheduled_start or not subtask.scheduled_end:
3963
+ return False, "Subtask must be scheduled before resources can be allocated"
3964
+
3965
+ # For each required resource
3966
+ resource_assignments = []
3967
+ for resource_req in subtask.required_resources:
3968
+ resource_type = resource_req.get("resource_type")
3969
+ requirements = resource_req.get("requirements", "")
3970
+ quantity = resource_req.get("quantity", 1)
3971
+
3972
+ # Find available resources matching the requirements
3973
+ resources = await resource_service.find_available_resources(
3974
+ start_time=subtask.scheduled_start,
3975
+ end_time=subtask.scheduled_end,
3976
+ resource_type=resource_type,
3977
+ tags=requirements.split() if requirements else None,
3978
+ capacity=None # Could use quantity here if it represents capacity
3979
+ )
3980
+
3981
+ if not resources or len(resources) < quantity:
3982
+ return False, f"Insufficient {resource_type} resources available"
3983
+
3984
+ # Allocate the resources by creating bookings
3985
+ allocated_resources = []
3986
+ for i in range(quantity):
3987
+ if i >= len(resources):
3988
+ break
3989
+
3990
+ resource = resources[i]
3991
+ success, booking_id, error = await resource_service.create_booking(
3992
+ resource_id=resource.id,
3993
+ user_id=subtask.assigned_to or "system",
3994
+ # Use query instead of title
3995
+ title=f"Task: {subtask.query}",
3996
+ start_time=subtask.scheduled_start,
3997
+ end_time=subtask.scheduled_end,
3998
+ description=subtask.description
3999
+ )
4000
+
4001
+ if success:
4002
+ allocated_resources.append({
4003
+ "resource_id": resource.id,
4004
+ "resource_name": resource.name,
4005
+ "booking_id": booking_id,
4006
+ "resource_type": resource.resource_type
4007
+ })
4008
+ else:
4009
+ # Clean up any allocations already made
4010
+ for alloc in allocated_resources:
4011
+ await resource_service.cancel_booking(alloc["booking_id"], subtask.assigned_to or "system")
4012
+ return False, f"Failed to book resource: {error}"
4013
+
4014
+ resource_assignments.append({
4015
+ "requirement": resource_req,
4016
+ "allocated": allocated_resources
4017
+ })
4018
+
4019
+ # Update the subtask with resource assignments
4020
+ subtask.resource_assignments = resource_assignments
4021
+ self.ticket_repository.update(subtask_id, {
4022
+ "resource_assignments": resource_assignments,
4023
+ "updated_at": datetime.datetime.now(datetime.timezone.utc)
4024
+ })
4025
+
4026
+ return True, f"Successfully allocated {len(resource_assignments)} resource types"
4027
+
3011
4028
 
3012
4029
  class ProjectApprovalService:
3013
4030
  """Service for managing human approval of new projects."""
@@ -3777,6 +4794,77 @@ class SchedulingService:
3777
4794
 
3778
4795
  return task
3779
4796
 
4797
+ async def find_optimal_time_slot_with_resources(
4798
+ self,
4799
+ task: ScheduledTask,
4800
+ resource_service: ResourceService,
4801
+ agent_schedule: Optional[AgentSchedule] = None
4802
+ ) -> Optional[TimeWindow]:
4803
+ """Find the optimal time slot for a task based on both agent and resource availability."""
4804
+ if not task.assigned_to:
4805
+ return None
4806
+
4807
+ # First, find potential time slots based on agent availability
4808
+ agent_id = task.assigned_to
4809
+ duration = task.estimated_minutes or 30
4810
+
4811
+ # Start no earlier than now
4812
+ start_after = datetime.datetime.now(datetime.timezone.utc)
4813
+
4814
+ # Apply task constraints
4815
+ for constraint in task.constraints:
4816
+ if constraint.get("type") == "must_start_after" and constraint.get("time"):
4817
+ constraint_time = datetime.datetime.fromisoformat(
4818
+ constraint["time"])
4819
+ if constraint_time > start_after:
4820
+ start_after = constraint_time
4821
+
4822
+ # Get potential time slots for the agent
4823
+ agent_slots = await self.find_available_time_slots(
4824
+ agent_id,
4825
+ duration,
4826
+ start_after,
4827
+ count=3, # Get multiple slots to try with resources
4828
+ agent_schedule=agent_schedule
4829
+ )
4830
+
4831
+ if not agent_slots:
4832
+ return None
4833
+
4834
+ # Check if task has resource requirements
4835
+ required_resources = getattr(task, "required_resources", [])
4836
+ if not required_resources:
4837
+ # If no resources needed, return the first available agent slot
4838
+ return agent_slots[0]
4839
+
4840
+ # For each potential time slot, check resource availability
4841
+ for time_slot in agent_slots:
4842
+ all_resources_available = True
4843
+
4844
+ for resource_req in required_resources:
4845
+ resource_type = resource_req.get("resource_type")
4846
+ requirements = resource_req.get("requirements", "")
4847
+ quantity = resource_req.get("quantity", 1)
4848
+
4849
+ # Find available resources for this time slot
4850
+ resources = await resource_service.find_available_resources(
4851
+ start_time=time_slot.start,
4852
+ end_time=time_slot.end,
4853
+ resource_type=resource_type,
4854
+ tags=requirements.split() if requirements else None
4855
+ )
4856
+
4857
+ if len(resources) < quantity:
4858
+ all_resources_available = False
4859
+ break
4860
+
4861
+ # If all resources are available, use this time slot
4862
+ if all_resources_available:
4863
+ return time_slot
4864
+
4865
+ # If no time slot has all resources available, default to first slot
4866
+ return agent_slots[0]
4867
+
3780
4868
  async def optimize_schedule(self) -> Dict[str, Any]:
3781
4869
  """Optimize the entire schedule to maximize efficiency."""
3782
4870
  # Get all pending and scheduled tasks
@@ -4431,6 +5519,7 @@ class SchedulingService:
4431
5519
 
4432
5520
  return formatted_requests
4433
5521
 
5522
+
4434
5523
  #############################################
4435
5524
  # MAIN AGENT PROCESSOR
4436
5525
  #############################################
@@ -4498,28 +5587,18 @@ class QueryProcessor:
4498
5587
  self._stalled_ticket_task = loop.create_task(
4499
5588
  self._run_stalled_ticket_checks())
4500
5589
  except RuntimeError:
4501
- # No running event loop - likely in test environment
4502
- # Instead of just passing, log a message for clarity
4503
5590
  import logging
4504
5591
  logging.warning(
4505
5592
  "No running event loop available for stalled ticket checker.")
4506
- # Don't try to create the task - this prevents the coroutine warning
4507
5593
 
4508
5594
  try:
4509
- # Handle human agent messages differently
4510
- if await self._is_human_agent(user_id):
4511
- async for chunk in self._process_human_agent_message(
4512
- user_id, user_text
4513
- ):
4514
- yield chunk
4515
- return
4516
-
4517
- # Handle simple greetings without full agent routing
4518
- if await self._is_simple_greeting(user_text):
4519
- greeting_response = await self._generate_greeting_response(
4520
- user_id, user_text
4521
- )
4522
- yield greeting_response
5595
+ # Special case for "test" and other very simple messages
5596
+ if user_text.strip().lower() in ["test", "hello", "hi", "hey", "ping"]:
5597
+ response = f"Hello! How can I help you today?"
5598
+ yield response
5599
+ # Store this simple interaction in memory
5600
+ if self.memory_provider:
5601
+ await self._store_conversation(user_id, user_text, response)
4523
5602
  return
4524
5603
 
4525
5604
  # Handle system commands
@@ -4528,100 +5607,66 @@ class QueryProcessor:
4528
5607
  yield command_response
4529
5608
  return
4530
5609
 
5610
+ # Route to appropriate agent
5611
+ agent_name = await self.routing_service.route_query(user_text)
5612
+
4531
5613
  # Check for active ticket
4532
5614
  active_ticket = self.ticket_service.ticket_repository.get_active_for_user(
4533
- user_id
4534
- )
5615
+ user_id)
4535
5616
 
4536
5617
  if active_ticket:
4537
5618
  # Process existing ticket
4538
- async for chunk in self._process_existing_ticket(
4539
- user_id, user_text, active_ticket, timezone
4540
- ):
4541
- yield chunk
5619
+ try:
5620
+ response_buffer = ""
5621
+ async for chunk in self._process_existing_ticket(user_id, user_text, active_ticket, timezone):
5622
+ response_buffer += chunk
5623
+ yield chunk
5624
+
5625
+ # Check final response for unprocessed JSON
5626
+ if response_buffer.strip().startswith('{'):
5627
+ agent_name = active_ticket.assigned_to or "default_agent"
5628
+ processed_response = self.agent_service.process_json_response(
5629
+ response_buffer, agent_name)
5630
+ if processed_response != response_buffer:
5631
+ yield "\n\n" + processed_response
5632
+ except ValueError as e:
5633
+ if "Ticket" in str(e) and "not found" in str(e):
5634
+ # Ticket no longer exists - create a new one
5635
+ complexity = await self._assess_task_complexity(user_text)
5636
+ async for chunk in self._process_new_ticket(user_id, user_text, complexity, timezone):
5637
+ yield chunk
5638
+ else:
5639
+ yield f"I'm sorry, I encountered an error: {str(e)}"
5640
+
4542
5641
  else:
4543
5642
  # Create new ticket
4544
- complexity = await self._assess_task_complexity(user_text)
4545
-
4546
- # Process as new ticket
4547
- async for chunk in self._process_new_ticket(
4548
- user_id, user_text, complexity, timezone
4549
- ):
4550
- yield chunk
5643
+ try:
5644
+ complexity = await self._assess_task_complexity(user_text)
5645
+
5646
+ # Process as new ticket
5647
+ response_buffer = ""
5648
+ async for chunk in self._process_new_ticket(user_id, user_text, complexity, timezone):
5649
+ response_buffer += chunk
5650
+ yield chunk
5651
+
5652
+ # Check final response for unprocessed JSON
5653
+ if response_buffer.strip().startswith('{'):
5654
+ processed_response = self.agent_service.process_json_response(
5655
+ response_buffer, agent_name)
5656
+ if processed_response != response_buffer:
5657
+ yield "\n\n" + processed_response
5658
+ except Exception as e:
5659
+ yield f"I'm sorry, I encountered an error: {str(e)}"
4551
5660
 
4552
5661
  except Exception as e:
4553
5662
  print(f"Error in request processing: {str(e)}")
4554
5663
  print(traceback.format_exc())
4555
- # Use yield instead of direct function calling to avoid coroutine warning
4556
- error_msg = "\n\nI apologize for the technical difficulty.\n\n"
4557
- yield error_msg
5664
+ yield "I apologize for the technical difficulty.\n\n"
4558
5665
 
4559
5666
  async def _is_human_agent(self, user_id: str) -> bool:
4560
5667
  """Check if the user is a registered human agent."""
4561
5668
  return user_id in self.agent_service.get_all_human_agents()
4562
5669
 
4563
- async def _is_simple_greeting(self, text: str) -> bool:
4564
- """Determine if the user message is a simple greeting."""
4565
- text_lower = text.lower().strip()
4566
-
4567
- # Common greetings list
4568
- simple_greetings = [
4569
- "hello",
4570
- "hi",
4571
- "hey",
4572
- "greetings",
4573
- "good morning",
4574
- "good afternoon",
4575
- "good evening",
4576
- "what's up",
4577
- "how are you",
4578
- "how's it going",
4579
- ]
4580
-
4581
- # Check if text starts with a greeting and is relatively short
4582
- is_greeting = any(
4583
- text_lower.startswith(greeting) for greeting in simple_greetings
4584
- )
4585
- # Arbitrary threshold for "simple" messages
4586
- is_short = len(text.split()) < 7
4587
-
4588
- return is_greeting and is_short
4589
-
4590
- async def _generate_greeting_response(self, user_id: str, text: str) -> str:
4591
- """Generate a friendly response to a simple greeting."""
4592
- # Get user context if available
4593
- context = ""
4594
- if self.memory_provider:
4595
- context = await self.memory_provider.retrieve(user_id)
4596
-
4597
- # Get first available AI agent for the greeting
4598
- first_agent_name = next(
4599
- iter(self.agent_service.get_all_ai_agents().keys()))
4600
-
4601
- response = ""
4602
- async for chunk in self.agent_service.generate_response(
4603
- first_agent_name,
4604
- user_id,
4605
- text,
4606
- context,
4607
- temperature=0.7,
4608
- max_tokens=100, # Keep it brief
4609
- ):
4610
- response += chunk
4611
-
4612
- # Store in memory if available
4613
- if self.memory_provider:
4614
- await self.memory_provider.store(
4615
- user_id,
4616
- [
4617
- {"role": "user", "content": text},
4618
- {"role": "assistant",
4619
- "content": self._truncate(response, 2500)},
4620
- ],
4621
- )
4622
-
4623
- return response
4624
-
4625
5670
  async def shutdown(self):
4626
5671
  """Clean shutdown of the query processor."""
4627
5672
  self._shutdown_event.set()
@@ -4656,34 +5701,48 @@ class QueryProcessor:
4656
5701
  if self.stalled_ticket_timeout is None:
4657
5702
  return
4658
5703
 
4659
- # Find tickets that haven't been updated in the configured time
4660
- stalled_cutoff = datetime.datetime.now(
4661
- datetime.timezone.utc) - datetime.timedelta(minutes=self.stalled_ticket_timeout)
5704
+ try:
5705
+ # Find tickets that haven't been updated in the configured time
5706
+ cutoff_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
5707
+ minutes=self.stalled_ticket_timeout
5708
+ )
4662
5709
 
4663
- # Query for stalled tickets using the find_stalled_tickets method
4664
- stalled_tickets = await self.ticket_service.ticket_repository.find_stalled_tickets(
4665
- stalled_cutoff, [TicketStatus.ACTIVE, TicketStatus.TRANSFERRED]
4666
- )
5710
+ # The find_stalled_tickets method should be async
5711
+ stalled_tickets = await self.ticket_service.ticket_repository.find_stalled_tickets(
5712
+ cutoff_time, [TicketStatus.ACTIVE, TicketStatus.TRANSFERRED]
5713
+ )
4667
5714
 
4668
- for ticket in stalled_tickets:
4669
- # Re-route using routing service to find the optimal agent
4670
- new_agent = await self.routing_service.route_query(ticket.query)
5715
+ for ticket in stalled_tickets:
5716
+ print(
5717
+ f"Found stalled ticket: {ticket.id} (last updated: {ticket.updated_at})")
4671
5718
 
4672
- # Skip if the routing didn't change
4673
- if new_agent == ticket.assigned_to:
4674
- continue
5719
+ # Skip tickets without an assigned agent
5720
+ if not ticket.assigned_to:
5721
+ continue
4675
5722
 
4676
- # Process as handoff
4677
- await self.handoff_service.process_handoff(
4678
- ticket.id,
4679
- ticket.assigned_to or "unassigned",
4680
- new_agent,
4681
- f"Automatically reassigned after {self.stalled_ticket_timeout} minutes of inactivity"
4682
- )
5723
+ # Re-route the query to see if a different agent is better
5724
+ new_agent = await self.routing_service.route_query(ticket.query)
4683
5725
 
4684
- # Log the reassignment
4685
- print(
4686
- f"Stalled ticket {ticket.id} reassigned from {ticket.assigned_to or 'unassigned'} to {new_agent}")
5726
+ # Only reassign if a different agent is suggested
5727
+ if new_agent != ticket.assigned_to:
5728
+ print(
5729
+ f"Reassigning ticket {ticket.id} from {ticket.assigned_to} to {new_agent}")
5730
+
5731
+ try:
5732
+ await self.handoff_service.process_handoff(
5733
+ ticket.id,
5734
+ ticket.assigned_to,
5735
+ new_agent,
5736
+ f"Automatically reassigned after {self.stalled_ticket_timeout} minutes of inactivity"
5737
+ )
5738
+ except Exception as e:
5739
+ print(
5740
+ f"Error reassigning stalled ticket {ticket.id}: {e}")
5741
+
5742
+ except Exception as e:
5743
+ print(f"Error in stalled ticket check: {e}")
5744
+ import traceback
5745
+ print(traceback.format_exc())
4687
5746
 
4688
5747
  async def _process_system_commands(
4689
5748
  self, user_id: str, user_text: str
@@ -4709,7 +5768,7 @@ class QueryProcessor:
4709
5768
 
4710
5769
  return response
4711
5770
 
4712
- elif command == "!plan" and args:
5771
+ if command == "!plan" and args:
4713
5772
  # Create a new plan from the task description
4714
5773
  if not self.task_planning_service:
4715
5774
  return "Task planning service is not available."
@@ -4721,8 +5780,8 @@ class QueryProcessor:
4721
5780
  user_id, args, complexity
4722
5781
  )
4723
5782
 
4724
- # Generate subtasks
4725
- subtasks = await self.task_planning_service.generate_subtasks(
5783
+ # Generate subtasks with resource requirements
5784
+ subtasks = await self.task_planning_service.generate_subtasks_with_resources(
4726
5785
  ticket.id, args
4727
5786
  )
4728
5787
 
@@ -4736,17 +5795,44 @@ class QueryProcessor:
4736
5795
  for i, subtask in enumerate(subtasks, 1):
4737
5796
  response += f"{i}. **{subtask.title}**\n"
4738
5797
  response += f" - Description: {subtask.description}\n"
4739
- response += (
4740
- f" - Estimated time: {subtask.estimated_minutes} minutes\n"
4741
- )
5798
+ response += f" - Estimated time: {subtask.estimated_minutes} minutes\n"
5799
+
5800
+ if subtask.required_resources:
5801
+ response += f" - Required resources:\n"
5802
+ for res in subtask.required_resources:
5803
+ res_type = res.get("resource_type", "unknown")
5804
+ quantity = res.get("quantity", 1)
5805
+ requirements = res.get("requirements", "")
5806
+ response += f" * {quantity} {res_type}" + \
5807
+ (f" ({requirements})" if requirements else "") + "\n"
5808
+
4742
5809
  if subtask.dependencies:
4743
- response += (
4744
- f" - Dependencies: {len(subtask.dependencies)} subtasks\n"
4745
- )
5810
+ response += f" - Dependencies: {len(subtask.dependencies)} subtasks\n"
5811
+
4746
5812
  response += "\n"
4747
5813
 
4748
5814
  return response
4749
5815
 
5816
+ # Add a new command for allocating resources to tasks
5817
+ elif command == "!allocate-resources" and args:
5818
+ parts = args.split()
5819
+ if len(parts) < 1:
5820
+ return "Usage: !allocate-resources [subtask_id]"
5821
+
5822
+ subtask_id = parts[0]
5823
+
5824
+ if not hasattr(self, "resource_service") or not self.resource_service:
5825
+ return "Resource service is not available."
5826
+
5827
+ success, message = await self.task_planning_service.allocate_resources(
5828
+ subtask_id, self.resource_service
5829
+ )
5830
+
5831
+ if success:
5832
+ return f"✅ Resources allocated successfully: {message}"
5833
+ else:
5834
+ return f"❌ Failed to allocate resources: {message}"
5835
+
4750
5836
  elif command == "!status" and args:
4751
5837
  # Show status of a specific plan
4752
5838
  if not self.task_planning_service:
@@ -4993,117 +6079,482 @@ class QueryProcessor:
4993
6079
 
4994
6080
  return response
4995
6081
 
6082
+ elif command == "!resources" and self.resource_service:
6083
+ # Format: !resources [list|find|show|create|update|delete]
6084
+ parts = args.strip().split(" ", 1)
6085
+ subcommand = parts[0] if parts else "list"
6086
+ subcmd_args = parts[1] if len(parts) > 1 else ""
6087
+
6088
+ if subcommand == "list":
6089
+ # List available resources, optionally filtered by type
6090
+ resource_type = subcmd_args if subcmd_args else None
6091
+
6092
+ query = {}
6093
+ if resource_type:
6094
+ query["resource_type"] = resource_type
6095
+
6096
+ resources = self.resource_service.repository.find_resources(
6097
+ query)
6098
+
6099
+ if not resources:
6100
+ return "No resources found."
6101
+
6102
+ response = "# Available Resources\n\n"
6103
+
6104
+ # Group by type
6105
+ resources_by_type = {}
6106
+ for resource in resources:
6107
+ r_type = resource.resource_type
6108
+ if r_type not in resources_by_type:
6109
+ resources_by_type[r_type] = []
6110
+ resources_by_type[r_type].append(resource)
6111
+
6112
+ for r_type, r_list in resources_by_type.items():
6113
+ response += f"## {r_type.capitalize()}\n\n"
6114
+ for resource in r_list:
6115
+ status_emoji = "🟢" if resource.status == "available" else "🔴"
6116
+ response += f"{status_emoji} **{resource.name}** (ID: {resource.id})\n"
6117
+ if resource.description:
6118
+ response += f" {resource.description}\n"
6119
+ if resource.location and resource.location.building:
6120
+ response += f" Location: {resource.location.building}"
6121
+ if resource.location.room:
6122
+ response += f", Room {resource.location.room}"
6123
+ response += "\n"
6124
+ if resource.capacity:
6125
+ response += f" Capacity: {resource.capacity}\n"
6126
+ response += "\n"
6127
+
6128
+ return response
6129
+
6130
+ elif subcommand == "show" and subcmd_args:
6131
+ # Show details for a specific resource
6132
+ resource_id = subcmd_args.strip()
6133
+ resource = await self.resource_service.get_resource(resource_id)
6134
+
6135
+ if not resource:
6136
+ return f"Resource with ID {resource_id} not found."
6137
+
6138
+ response = f"# Resource: {resource.name}\n\n"
6139
+ response += f"**ID**: {resource.id}\n"
6140
+ response += f"**Type**: {resource.resource_type}\n"
6141
+ response += f"**Status**: {resource.status}\n"
6142
+
6143
+ if resource.description:
6144
+ response += f"\n**Description**: {resource.description}\n"
6145
+
6146
+ if resource.location:
6147
+ response += "\n**Location**:\n"
6148
+ if resource.location.address:
6149
+ response += f"- Address: {resource.location.address}\n"
6150
+ if resource.location.building:
6151
+ response += f"- Building: {resource.location.building}\n"
6152
+ if resource.location.floor is not None:
6153
+ response += f"- Floor: {resource.location.floor}\n"
6154
+ if resource.location.room:
6155
+ response += f"- Room: {resource.location.room}\n"
6156
+
6157
+ if resource.capacity:
6158
+ response += f"\n**Capacity**: {resource.capacity}\n"
6159
+
6160
+ if resource.tags:
6161
+ response += f"\n**Tags**: {', '.join(resource.tags)}\n"
6162
+
6163
+ # Show availability schedule
6164
+ if resource.availability_schedule:
6165
+ response += "\n**Regular Availability**:\n"
6166
+ for window in resource.availability_schedule:
6167
+ days = "Every day"
6168
+ if window.day_of_week:
6169
+ day_names = [
6170
+ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
6171
+ days = ", ".join([day_names[d]
6172
+ for d in window.day_of_week])
6173
+ response += f"- {days}: {window.start_time} - {window.end_time} ({window.timezone})\n"
6174
+
6175
+ # Show upcoming bookings
6176
+ now = datetime.datetime.now(datetime.timezone.utc)
6177
+ next_month = now + datetime.timedelta(days=30)
6178
+ bookings = self.resource_service.repository.get_resource_bookings(
6179
+ resource_id, now, next_month)
6180
+
6181
+ if bookings:
6182
+ response += "\n**Upcoming Bookings**:\n"
6183
+ for booking in bookings:
6184
+ start_str = booking.start_time.strftime(
6185
+ "%Y-%m-%d %H:%M")
6186
+ end_str = booking.end_time.strftime("%H:%M")
6187
+ response += f"- {start_str} - {end_str}: {booking.title}\n"
6188
+
6189
+ return response
6190
+
6191
+ elif subcommand == "find":
6192
+ # Find available resources for a time period
6193
+ # Format: !resources find room 2023-03-15 14:00 16:00
6194
+ parts = subcmd_args.split()
6195
+ if len(parts) < 4:
6196
+ return "Usage: !resources find [type] [date] [start_time] [end_time] [capacity]"
6197
+
6198
+ resource_type = parts[0]
6199
+ date_str = parts[1]
6200
+ start_time_str = parts[2]
6201
+ end_time_str = parts[3]
6202
+ capacity = int(parts[4]) if len(parts) > 4 else None
6203
+
6204
+ try:
6205
+ # Parse date and times
6206
+ date_obj = datetime.datetime.strptime(
6207
+ date_str, "%Y-%m-%d").date()
6208
+ start_time = datetime.datetime.combine(
6209
+ date_obj,
6210
+ datetime.datetime.strptime(
6211
+ start_time_str, "%H:%M").time(),
6212
+ tzinfo=datetime.timezone.utc
6213
+ )
6214
+ end_time = datetime.datetime.combine(
6215
+ date_obj,
6216
+ datetime.datetime.strptime(
6217
+ end_time_str, "%H:%M").time(),
6218
+ tzinfo=datetime.timezone.utc
6219
+ )
6220
+
6221
+ # Find available resources
6222
+ resources = await self.resource_service.find_available_resources(
6223
+ resource_type=resource_type,
6224
+ start_time=start_time,
6225
+ end_time=end_time,
6226
+ capacity=capacity
6227
+ )
6228
+
6229
+ if not resources:
6230
+ return f"No {resource_type}s available for the requested time period."
6231
+
6232
+ response = f"# Available {resource_type.capitalize()}s\n\n"
6233
+ response += f"**Date**: {date_str}\n"
6234
+ response += f"**Time**: {start_time_str} - {end_time_str}\n"
6235
+ if capacity:
6236
+ response += f"**Minimum Capacity**: {capacity}\n"
6237
+ response += "\n"
6238
+
6239
+ for resource in resources:
6240
+ response += f"- **{resource.name}** (ID: {resource.id})\n"
6241
+ if resource.description:
6242
+ response += f" {resource.description}\n"
6243
+ if resource.capacity:
6244
+ response += f" Capacity: {resource.capacity}\n"
6245
+ if resource.location and resource.location.building:
6246
+ response += f" Location: {resource.location.building}"
6247
+ if resource.location.room:
6248
+ response += f", Room {resource.location.room}"
6249
+ response += "\n"
6250
+ response += "\n"
6251
+
6252
+ return response
6253
+
6254
+ except ValueError as e:
6255
+ return f"Error parsing date/time: {e}"
6256
+
6257
+ elif subcommand == "book":
6258
+ # Book a resource
6259
+ # Format: !resources book [resource_id] [date] [start_time] [end_time] [title]
6260
+ parts = subcmd_args.split(" ", 5)
6261
+ if len(parts) < 5:
6262
+ return "Usage: !resources book [resource_id] [date] [start_time] [end_time] [title]"
6263
+
6264
+ resource_id = parts[0]
6265
+ date_str = parts[1]
6266
+ start_time_str = parts[2]
6267
+ end_time_str = parts[3]
6268
+ title = parts[4] if len(parts) > 4 else "Booking"
6269
+
6270
+ try:
6271
+ # Parse date and times
6272
+ date_obj = datetime.datetime.strptime(
6273
+ date_str, "%Y-%m-%d").date()
6274
+ start_time = datetime.datetime.combine(
6275
+ date_obj,
6276
+ datetime.datetime.strptime(
6277
+ start_time_str, "%H:%M").time(),
6278
+ tzinfo=datetime.timezone.utc
6279
+ )
6280
+ end_time = datetime.datetime.combine(
6281
+ date_obj,
6282
+ datetime.datetime.strptime(
6283
+ end_time_str, "%H:%M").time(),
6284
+ tzinfo=datetime.timezone.utc
6285
+ )
6286
+
6287
+ # Create booking
6288
+ success, booking, error = await self.resource_service.create_booking(
6289
+ resource_id=resource_id,
6290
+ user_id=user_id,
6291
+ title=title,
6292
+ start_time=start_time,
6293
+ end_time=end_time
6294
+ )
6295
+
6296
+ if not success:
6297
+ return f"Failed to book resource: {error}"
6298
+
6299
+ # Get resource details
6300
+ resource = await self.resource_service.get_resource(resource_id)
6301
+ resource_name = resource.name if resource else resource_id
6302
+
6303
+ response = "# Booking Confirmed\n\n"
6304
+ response += f"**Resource**: {resource_name}\n"
6305
+ response += f"**Date**: {date_str}\n"
6306
+ response += f"**Time**: {start_time_str} - {end_time_str}\n"
6307
+ response += f"**Title**: {title}\n"
6308
+ response += f"**Booking ID**: {booking.id}\n\n"
6309
+ response += "Your booking has been confirmed and added to your schedule."
6310
+
6311
+ return response
6312
+
6313
+ except ValueError as e:
6314
+ return f"Error parsing date/time: {e}"
6315
+
6316
+ elif subcommand == "bookings":
6317
+ # View all bookings for the current user
6318
+ include_cancelled = "all" in subcmd_args.lower()
6319
+
6320
+ bookings = await self.resource_service.get_user_bookings(user_id, include_cancelled)
6321
+
6322
+ if not bookings:
6323
+ return "You don't have any bookings." + (
6324
+ " (Use 'bookings all' to include cancelled bookings)" if not include_cancelled else ""
6325
+ )
6326
+
6327
+ response = "# Your Bookings\n\n"
6328
+
6329
+ # Group bookings by date
6330
+ bookings_by_date = {}
6331
+ for booking_data in bookings:
6332
+ booking = booking_data["booking"]
6333
+ resource = booking_data["resource"]
6334
+
6335
+ date_str = booking["start_time"].strftime("%Y-%m-%d")
6336
+ if date_str not in bookings_by_date:
6337
+ bookings_by_date[date_str] = []
6338
+
6339
+ bookings_by_date[date_str].append((booking, resource))
6340
+
6341
+ # Sort dates
6342
+ for date_str in sorted(bookings_by_date.keys()):
6343
+ response += f"## {date_str}\n\n"
6344
+
6345
+ for booking, resource in bookings_by_date[date_str]:
6346
+ start_time = booking["start_time"].strftime(
6347
+ "%H:%M")
6348
+ end_time = booking["end_time"].strftime("%H:%M")
6349
+ resource_name = resource["name"] if resource else "Unknown Resource"
6350
+
6351
+ status_emoji = "🟢" if booking["status"] == "confirmed" else "🔴"
6352
+ response += f"{status_emoji} **{start_time}-{end_time}**: {booking['title']}\n"
6353
+ response += f" Resource: {resource_name}\n"
6354
+ response += f" Booking ID: {booking['id']}\n\n"
6355
+
6356
+ return response
6357
+
6358
+ elif subcommand == "cancel" and subcmd_args:
6359
+ # Cancel a booking
6360
+ booking_id = subcmd_args.strip()
6361
+
6362
+ success, error = await self.resource_service.cancel_booking(booking_id, user_id)
6363
+
6364
+ if success:
6365
+ return "✅ Your booking has been successfully cancelled."
6366
+ else:
6367
+ return f"❌ Failed to cancel booking: {error}"
6368
+
6369
+ elif subcommand == "schedule" and subcmd_args:
6370
+ # View resource schedule
6371
+ # Format: !resources schedule resource_id [YYYY-MM-DD] [days]
6372
+ parts = subcmd_args.split()
6373
+ if len(parts) < 1:
6374
+ return "Usage: !resources schedule resource_id [YYYY-MM-DD] [days]"
6375
+
6376
+ resource_id = parts[0]
6377
+
6378
+ # Default to today and 7 days
6379
+ start_date = datetime.datetime.now(datetime.timezone.utc)
6380
+ days = 7
6381
+
6382
+ if len(parts) > 1:
6383
+ try:
6384
+ start_date = datetime.datetime.strptime(
6385
+ parts[1], "%Y-%m-%d"
6386
+ ).replace(tzinfo=datetime.timezone.utc)
6387
+ except ValueError:
6388
+ return "Invalid date format. Use YYYY-MM-DD."
6389
+
6390
+ if len(parts) > 2:
6391
+ try:
6392
+ days = min(int(parts[2]), 31) # Limit to 31 days
6393
+ except ValueError:
6394
+ return "Days must be a number."
6395
+
6396
+ end_date = start_date + datetime.timedelta(days=days)
6397
+
6398
+ # Get the resource
6399
+ resource = await self.resource_service.get_resource(resource_id)
6400
+ if not resource:
6401
+ return f"Resource with ID {resource_id} not found."
6402
+
6403
+ # Get schedule
6404
+ schedule = await self.resource_service.get_resource_schedule(
6405
+ resource_id, start_date, end_date
6406
+ )
6407
+
6408
+ # Create calendar visualization
6409
+ response = f"# Schedule for {resource.name}\n\n"
6410
+ response += f"Period: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}\n\n"
6411
+
6412
+ # Group by date
6413
+ schedule_by_date = {}
6414
+ current_date = start_date
6415
+ while current_date < end_date:
6416
+ date_str = current_date.strftime("%Y-%m-%d")
6417
+ schedule_by_date[date_str] = []
6418
+ current_date += datetime.timedelta(days=1)
6419
+
6420
+ # Add entries to appropriate dates
6421
+ for entry in schedule:
6422
+ date_str = entry["start_time"].strftime("%Y-%m-%d")
6423
+ if date_str in schedule_by_date:
6424
+ schedule_by_date[date_str].append(entry)
6425
+
6426
+ # Generate calendar view
6427
+ for date_str, entries in schedule_by_date.items():
6428
+ # Convert to datetime for day of week
6429
+ entry_date = datetime.datetime.strptime(
6430
+ date_str, "%Y-%m-%d")
6431
+ day_of_week = entry_date.strftime("%A")
6432
+
6433
+ response += f"## {date_str} ({day_of_week})\n\n"
6434
+
6435
+ if not entries:
6436
+ response += "No bookings or exceptions\n\n"
6437
+ continue
6438
+
6439
+ # Sort by start time
6440
+ entries.sort(key=lambda x: x["start_time"])
6441
+
6442
+ for entry in entries:
6443
+ start_time = entry["start_time"].strftime("%H:%M")
6444
+ end_time = entry["end_time"].strftime("%H:%M")
6445
+
6446
+ if entry["type"] == "booking":
6447
+ response += f"- **{start_time}-{end_time}**: {entry['title']} (by {entry['user_id']})\n"
6448
+ else: # exception
6449
+ response += f"- **{start_time}-{end_time}**: {entry['status']} (Unavailable)\n"
6450
+
6451
+ response += "\n"
6452
+
6453
+ return response
6454
+
4996
6455
  return None
4997
6456
 
4998
6457
  async def _process_existing_ticket(
4999
6458
  self, user_id: str, user_text: str, ticket: Ticket, timezone: str = None
5000
6459
  ) -> AsyncGenerator[str, None]:
5001
- """Process a message for an existing ticket."""
6460
+ """
6461
+ Process a message for an existing ticket.
6462
+ Checks for handoff data and handles it with ticket-based handoff
6463
+ unless the target agent is set to skip ticket creation.
6464
+ """
5002
6465
  # Get assigned agent or re-route if needed
5003
6466
  agent_name = ticket.assigned_to
5004
-
5005
- # If no valid assignment, route to appropriate agent
5006
- if not agent_name or agent_name not in self.agent_service.get_all_ai_agents():
6467
+ if not agent_name:
5007
6468
  agent_name = await self.routing_service.route_query(user_text)
5008
- # Update ticket with new assignment
5009
6469
  self.ticket_service.update_ticket_status(
5010
- ticket.id, TicketStatus.ACTIVE, assigned_to=agent_name
6470
+ ticket.id, TicketStatus.IN_PROGRESS, assigned_to=agent_name
5011
6471
  )
5012
6472
 
5013
- # Update ticket status
5014
- self.ticket_service.update_ticket_status(
5015
- ticket.id, TicketStatus.ACTIVE)
5016
-
5017
6473
  # Get memory context if available
5018
6474
  memory_context = ""
5019
6475
  if self.memory_provider:
5020
6476
  memory_context = await self.memory_provider.retrieve(user_id)
5021
6477
 
5022
- # Generate response with streaming
6478
+ # Try to generate response
5023
6479
  full_response = ""
5024
6480
  handoff_info = None
6481
+ handoff_detected = False
5025
6482
 
5026
- async for chunk in self.agent_service.generate_response(
5027
- agent_name, user_id, user_text, memory_context, temperature=0.7
5028
- ):
5029
- yield chunk
5030
- full_response += chunk
5031
-
5032
- # Check for handoff in structured format
5033
6483
  try:
5034
- # Look for JSON handoff object in the response
5035
- handoff_match = re.search(r'{"handoff":\s*{.*?}}', full_response)
5036
- if handoff_match:
5037
- handoff_data = json.loads(handoff_match.group(0))
5038
- if "handoff" in handoff_data:
6484
+ # Generate response with streaming
6485
+ async for chunk in self.agent_service.generate_response(
6486
+ agent_name=agent_name,
6487
+ user_id=user_id,
6488
+ query=user_text,
6489
+ memory_context=memory_context,
6490
+ ):
6491
+ # Detect possible handoff signals (JSON or prefix)
6492
+ if chunk.strip().startswith("HANDOFF:") or (
6493
+ not full_response and chunk.strip().startswith("{")
6494
+ ):
6495
+ handoff_detected = True
6496
+ full_response += chunk
6497
+ continue
6498
+
6499
+ full_response += chunk
6500
+ yield chunk
6501
+
6502
+ # After response generation, handle handoff if needed
6503
+ if handoff_detected or (
6504
+ not full_response.strip()
6505
+ and hasattr(self.agent_service, "_last_handoff")
6506
+ ):
6507
+ if hasattr(self.agent_service, "_last_handoff") and self.agent_service._last_handoff:
6508
+ handoff_data = {
6509
+ "handoff": self.agent_service._last_handoff}
5039
6510
  target_agent = handoff_data["handoff"].get("target_agent")
5040
6511
  reason = handoff_data["handoff"].get("reason")
5041
- if target_agent and reason:
6512
+
6513
+ if target_agent:
5042
6514
  handoff_info = {
5043
6515
  "target": target_agent, "reason": reason}
5044
- except Exception as e:
5045
- print(f"Error parsing handoff data: {e}")
5046
-
5047
- # Fall back to old method if structured parsing fails
5048
- if "HANDOFF:" in chunk and not handoff_info:
5049
- handoff_pattern = r"HANDOFF:\s*([A-Za-z0-9_]+)\s*REASON:\s*(.+)"
5050
- match = re.search(handoff_pattern, full_response)
5051
- if match:
5052
- target_agent = match.group(1)
5053
- reason = match.group(2)
5054
- handoff_info = {"target": target_agent, "reason": reason}
5055
-
5056
- # Store conversation in memory if available
5057
- if self.memory_provider:
5058
- await self.memory_provider.store(
5059
- user_id,
5060
- [
5061
- {"role": "user", "content": user_text},
5062
- {
5063
- "role": "assistant",
5064
- "content": self._truncate(full_response, 2500),
5065
- },
5066
- ],
5067
- )
5068
6516
 
5069
- # Process handoff if detected
5070
- if handoff_info:
5071
- try:
5072
- await self.handoff_service.process_handoff(
5073
- ticket.id,
5074
- agent_name,
5075
- handoff_info["target"],
5076
- handoff_info["reason"],
5077
- )
5078
- except ValueError as e:
5079
- # If handoff fails, just continue with current agent
5080
- print(f"Handoff failed: {e}")
6517
+ await self.handoff_service.process_handoff(
6518
+ ticket.id,
6519
+ agent_name,
6520
+ handoff_info["target"],
6521
+ handoff_info["reason"],
6522
+ )
5081
6523
 
5082
- # Check if ticket can be considered resolved
5083
- if not handoff_info:
5084
- resolution = await self._check_ticket_resolution(
5085
- user_id, full_response, user_text
5086
- )
6524
+ print(
6525
+ f"Generating response from new agent: {target_agent}")
6526
+ new_response_buffer = ""
6527
+ async for chunk in self.agent_service.generate_response(
6528
+ agent_name=target_agent,
6529
+ user_id=user_id,
6530
+ query=user_text,
6531
+ memory_context=memory_context,
6532
+ ):
6533
+ new_response_buffer += chunk
6534
+ yield chunk
5087
6535
 
5088
- if resolution.status == "resolved" and resolution.confidence >= 0.7:
5089
- self.ticket_service.mark_ticket_resolved(
5090
- ticket.id,
5091
- {
5092
- "confidence": resolution.confidence,
5093
- "reasoning": resolution.reasoning,
5094
- },
5095
- )
6536
+ full_response = new_response_buffer
5096
6537
 
5097
- # Create NPS survey
5098
- self.nps_service.create_survey(user_id, ticket.id, agent_name)
6538
+ self.agent_service._last_handoff = None
5099
6539
 
5100
- # Extract and store insights in background
5101
- if full_response:
5102
- asyncio.create_task(
5103
- self._extract_and_store_insights(
5104
- user_id, {"message": user_text, "response": full_response}
6540
+ # Store conversation in memory
6541
+ if self.memory_provider:
6542
+ await self.memory_provider.store(
6543
+ user_id,
6544
+ [
6545
+ {"role": "user", "content": user_text},
6546
+ {
6547
+ "role": "assistant",
6548
+ "content": self._truncate(full_response, 2500),
6549
+ },
6550
+ ],
5105
6551
  )
5106
- )
6552
+
6553
+ except Exception as e:
6554
+ print(f"Error processing ticket: {str(e)}")
6555
+ import traceback
6556
+ print(traceback.format_exc())
6557
+ yield f"I'm sorry, I encountered an error processing your request: {str(e)}"
5107
6558
 
5108
6559
  async def _process_new_ticket(
5109
6560
  self,
@@ -5195,106 +6646,113 @@ class QueryProcessor:
5195
6646
  ticket.id, TicketStatus.ACTIVE, assigned_to=agent_name
5196
6647
  )
5197
6648
 
5198
- # Generate response with streaming
6649
+ # Generate initial response with streaming
5199
6650
  full_response = ""
5200
- handoff_info = None
6651
+ handoff_detected = False
5201
6652
 
5202
- async for chunk in self.agent_service.generate_response(
5203
- agent_name, user_id, user_text, memory_context, temperature=0.7
5204
- ):
5205
- yield chunk
5206
- full_response += chunk
6653
+ try:
6654
+ # Generate response with streaming
6655
+ async for chunk in self.agent_service.generate_response(
6656
+ agent_name, user_id, user_text, memory_context, temperature=0.7
6657
+ ):
6658
+ # Check if this looks like a JSON handoff
6659
+ if chunk.strip().startswith("{") and not handoff_detected:
6660
+ handoff_detected = True
6661
+ full_response += chunk
6662
+ continue
5207
6663
 
5208
- # Check for handoff in structured format
5209
- try:
5210
- # Look for JSON handoff object in the response
5211
- handoff_match = re.search(
5212
- r'{"handoff":\s*{.*?}}', full_response)
5213
- if handoff_match:
5214
- handoff_data = json.loads(handoff_match.group(0))
5215
- if "handoff" in handoff_data:
5216
- target_agent = handoff_data["handoff"].get(
5217
- "target_agent")
5218
- reason = handoff_data["handoff"].get("reason")
5219
- if target_agent and reason:
5220
- handoff_info = {
5221
- "target": target_agent, "reason": reason}
5222
- except Exception as e:
5223
- print(f"Error parsing handoff data: {e}")
5224
-
5225
- # Fall back to old method if structured parsing fails
5226
- if "HANDOFF:" in chunk and not handoff_info:
5227
- handoff_pattern = r"HANDOFF:\s*([A-Za-z0-9_]+)\s*REASON:\s*(.+)"
5228
- match = re.search(handoff_pattern, full_response)
5229
- if match:
5230
- target_agent = match.group(1)
5231
- reason = match.group(2)
5232
- handoff_info = {"target": target_agent, "reason": reason}
5233
-
5234
- # Store conversation in memory if available
5235
- if self.memory_provider:
5236
- await self.memory_provider.store(
5237
- user_id,
5238
- [
5239
- {"role": "user", "content": user_text},
5240
- {
5241
- "role": "assistant",
5242
- "content": self._truncate(full_response, 2500),
5243
- },
5244
- ],
6664
+ # Only yield if not a JSON chunk
6665
+ if not handoff_detected:
6666
+ yield chunk
6667
+ full_response += chunk
6668
+
6669
+ # Handle handoff if detected
6670
+ if handoff_detected or (hasattr(self.agent_service, "_last_handoff") and self.agent_service._last_handoff):
6671
+ target_agent = None
6672
+ reason = "Handoff detected"
6673
+
6674
+ # Process the handoff from _last_handoff property
6675
+ if hasattr(self.agent_service, "_last_handoff") and self.agent_service._last_handoff:
6676
+ target_agent = self.agent_service._last_handoff.get(
6677
+ "target_agent")
6678
+ reason = self.agent_service._last_handoff.get(
6679
+ "reason", "No reason provided")
6680
+
6681
+ if target_agent:
6682
+ try:
6683
+ # Process handoff and update ticket
6684
+ await self.handoff_service.process_handoff(
6685
+ ticket.id,
6686
+ agent_name,
6687
+ target_agent,
6688
+ reason,
6689
+ )
6690
+
6691
+ # Generate response from new agent
6692
+ print(
6693
+ f"Generating response from new agent after handoff: {target_agent}")
6694
+ new_response = ""
6695
+ async for chunk in self.agent_service.generate_response(
6696
+ target_agent,
6697
+ user_id,
6698
+ user_text,
6699
+ memory_context,
6700
+ temperature=0.7
6701
+ ):
6702
+ yield chunk
6703
+ new_response += chunk
6704
+
6705
+ # Update full response for storage
6706
+ full_response = new_response
6707
+ except ValueError as e:
6708
+ print(f"Handoff failed: {e}")
6709
+ yield f"\n\nNote: A handoff was attempted but failed: {str(e)}"
6710
+
6711
+ # Reset handoff state
6712
+ self.agent_service._last_handoff = None
6713
+
6714
+ # Check if ticket can be considered resolved
6715
+ resolution = await self._check_ticket_resolution(
6716
+ full_response, user_text
5245
6717
  )
5246
6718
 
5247
- # Process handoff if detected
5248
- if handoff_info:
5249
- try:
5250
- await self.handoff_service.process_handoff(
6719
+ if resolution.status == "resolved" and resolution.confidence >= 0.7:
6720
+ self.ticket_service.mark_ticket_resolved(
5251
6721
  ticket.id,
5252
- agent_name,
5253
- handoff_info["target"],
5254
- handoff_info["reason"],
6722
+ {
6723
+ "confidence": resolution.confidence,
6724
+ "reasoning": resolution.reasoning,
6725
+ },
5255
6726
  )
5256
- except ValueError as e:
5257
- print(f"Handoff failed: {e}")
5258
6727
 
5259
- # Process handoff if detected
5260
- if handoff_info:
5261
- try:
5262
- await self.handoff_service.process_handoff(
5263
- ticket.id,
5264
- agent_name,
5265
- handoff_info["target"],
5266
- handoff_info["reason"],
5267
- )
5268
- except ValueError as e:
5269
- print(f"Handoff failed: {e}")
5270
-
5271
- # Check if ticket can be considered resolved
5272
- if not handoff_info:
5273
- resolution = await self._check_ticket_resolution(
5274
- user_id, full_response, user_text
5275
- )
5276
-
5277
- if resolution.status == "resolved" and resolution.confidence >= 0.7:
5278
- self.ticket_service.mark_ticket_resolved(
5279
- ticket.id,
5280
- {
5281
- "confidence": resolution.confidence,
5282
- "reasoning": resolution.reasoning,
5283
- },
5284
- )
6728
+ # Create NPS survey
6729
+ self.nps_service.create_survey(
6730
+ user_id, ticket.id, agent_name)
5285
6731
 
5286
- # Create NPS survey
5287
- self.nps_service.create_survey(
5288
- user_id, ticket.id, agent_name)
6732
+ # Store in memory provider
6733
+ if self.memory_provider:
6734
+ await self.memory_provider.store(
6735
+ user_id,
6736
+ [
6737
+ {"role": "user", "content": user_text},
6738
+ {"role": "assistant", "content": self._truncate(
6739
+ full_response, 2500)},
6740
+ ],
6741
+ )
5289
6742
 
5290
- # Extract and store insights in background
5291
- if full_response:
5292
- asyncio.create_task(
5293
- self._extract_and_store_insights(
5294
- user_id, {"message": user_text,
5295
- "response": full_response}
5296
- )
6743
+ # Extract and store insights in background
6744
+ if full_response:
6745
+ asyncio.create_task(
6746
+ self._extract_and_store_insights(
6747
+ user_id, {"message": user_text,
6748
+ "response": full_response}
5297
6749
  )
6750
+ )
6751
+
6752
+ except Exception as e:
6753
+ print(f"Error in _process_new_ticket: {str(e)}")
6754
+ print(traceback.format_exc())
6755
+ yield f"I'm sorry, I encountered an error processing your request: {str(e)}"
5298
6756
 
5299
6757
  async def _process_human_agent_message(
5300
6758
  self, user_id: str, user_text: str
@@ -5398,7 +6856,7 @@ class QueryProcessor:
5398
6856
  return result
5399
6857
 
5400
6858
  async def _check_ticket_resolution(
5401
- self, user_id: str, response: str, query: str
6859
+ self, response: str, query: str
5402
6860
  ) -> TicketResolution:
5403
6861
  """Determine if a ticket can be considered resolved based on the response."""
5404
6862
  # Get first AI agent for analysis
@@ -5416,39 +6874,48 @@ class QueryProcessor:
5416
6874
  2. "needs_followup" - The assistant couldn't fully address the issue or more information is needed
5417
6875
  3. "cannot_determine" - Cannot tell if the issue is resolved
5418
6876
 
5419
- Return a JSON object with:
6877
+ Return a structured output with:
5420
6878
  - "status": One of the above values
5421
6879
  - "confidence": A score from 0.0 to 1.0 indicating confidence in this assessment
5422
6880
  - "reasoning": Brief explanation for your decision
5423
6881
  - "suggested_actions": Array of recommended next steps (if any)
5424
6882
  """
5425
6883
 
5426
- # Generate resolution assessment
5427
- resolution_text = ""
5428
- async for chunk in self.agent_service.generate_response(
5429
- first_agent,
5430
- "resolution_checker",
5431
- prompt,
5432
- "", # No memory context needed
5433
- stream=False,
5434
- temperature=0.2,
5435
- response_format={"type": "json_object"},
5436
- ):
5437
- resolution_text += chunk
5438
-
5439
6884
  try:
5440
- data = json.loads(resolution_text)
5441
- return TicketResolution(**data)
5442
- except Exception as e:
5443
- print(f"Error parsing resolution decision: {e}")
5444
- return TicketResolution(
5445
- status="cannot_determine",
5446
- confidence=0.2,
5447
- reasoning="Failed to analyze resolution status",
6885
+ # Use structured output parsing with the Pydantic model directly
6886
+ resolution = await self.agent_service.llm_provider.parse_structured_output(
6887
+ prompt=prompt,
6888
+ system_prompt="You are a resolution analysis system. Analyze conversations and determine if queries have been resolved.",
6889
+ model_class=TicketResolution,
6890
+ model=self.agent_service.ai_agents[first_agent].get(
6891
+ "model", "gpt-4o-mini"),
6892
+ temperature=0.2,
5448
6893
  )
6894
+ return resolution
6895
+ except Exception as e:
6896
+ print(f"Exception in resolution check: {e}")
6897
+
6898
+ # Default fallback if anything fails
6899
+ return TicketResolution(
6900
+ status="cannot_determine",
6901
+ confidence=0.2,
6902
+ reasoning="Failed to analyze resolution status",
6903
+ suggested_actions=["Review conversation manually"]
6904
+ )
5449
6905
 
5450
6906
  async def _assess_task_complexity(self, query: str) -> Dict[str, Any]:
5451
6907
  """Assess the complexity of a task using standardized metrics."""
6908
+ # Special handling for very simple messages
6909
+ if len(query.strip()) <= 10 and query.lower().strip() in ["test", "hello", "hi", "hey", "ping", "thanks"]:
6910
+ print(f"Using pre-defined complexity for simple message: {query}")
6911
+ return {
6912
+ "t_shirt_size": "XS",
6913
+ "story_points": 1,
6914
+ "estimated_minutes": 5,
6915
+ "technical_complexity": 1,
6916
+ "domain_knowledge": 1,
6917
+ }
6918
+
5452
6919
  # Get first AI agent for analysis
5453
6920
  first_agent = next(iter(self.agent_service.get_all_ai_agents().keys()))
5454
6921
 
@@ -5478,16 +6945,28 @@ class QueryProcessor:
5478
6945
  ):
5479
6946
  response_text += chunk
5480
6947
 
6948
+ if not response_text.strip():
6949
+ print("Empty response from complexity assessment")
6950
+ return {
6951
+ "t_shirt_size": "S",
6952
+ "story_points": 2,
6953
+ "estimated_minutes": 15,
6954
+ "technical_complexity": 3,
6955
+ "domain_knowledge": 2,
6956
+ }
6957
+
5481
6958
  complexity_data = json.loads(response_text)
6959
+ print(f"Successfully parsed complexity: {complexity_data}")
5482
6960
  return complexity_data
5483
6961
  except Exception as e:
5484
6962
  print(f"Error assessing complexity: {e}")
6963
+ print(f"Failed response text: '{response_text}'")
5485
6964
  return {
5486
- "t_shirt_size": "M",
5487
- "story_points": 3,
5488
- "estimated_minutes": 30,
5489
- "technical_complexity": 5,
5490
- "domain_knowledge": 5,
6965
+ "t_shirt_size": "S",
6966
+ "story_points": 2,
6967
+ "estimated_minutes": 15,
6968
+ "technical_complexity": 3,
6969
+ "domain_knowledge": 2,
5491
6970
  }
5492
6971
 
5493
6972
  async def _extract_and_store_insights(
@@ -5522,6 +7001,20 @@ class QueryProcessor:
5522
7001
 
5523
7002
  return truncated + "..."
5524
7003
 
7004
+ async def _store_conversation(self, user_id: str, user_text: str, response_text: str) -> None:
7005
+ """Store conversation history in memory provider."""
7006
+ if self.memory_provider:
7007
+ try:
7008
+ await self.memory_provider.store(
7009
+ user_id,
7010
+ [
7011
+ {"role": "user", "content": user_text},
7012
+ {"role": "assistant", "content": response_text},
7013
+ ],
7014
+ )
7015
+ except Exception as e:
7016
+ print(f"Error storing conversation: {e}")
7017
+ # Don't let memory storage errors affect the user experience
5525
7018
 
5526
7019
  #############################################
5527
7020
  # FACTORY AND DEPENDENCY INJECTION
@@ -5599,16 +7092,25 @@ class SolanaAgentFactory:
5599
7092
 
5600
7093
  # Create services
5601
7094
  agent_service = AgentService(
5602
- llm_adapter, human_agent_repo, ai_agent_repo, organization_mission)
7095
+ llm_adapter, human_agent_repo, ai_agent_repo, organization_mission, config)
7096
+
7097
+ # Debug the agent service tool registry to confirm tools were registered
7098
+ print(
7099
+ f"Agent service tools after initialization: {agent_service.tool_registry.list_all_tools()}")
7100
+
5603
7101
  routing_service = RoutingService(
5604
7102
  llm_adapter,
5605
7103
  agent_service,
5606
7104
  router_model=config.get("router_model", "gpt-4o-mini"),
5607
7105
  )
7106
+
5608
7107
  ticket_service = TicketService(ticket_repo)
7108
+
5609
7109
  handoff_service = HandoffService(
5610
7110
  handoff_repo, ticket_repo, agent_service)
7111
+
5611
7112
  memory_service = MemoryService(memory_repo, llm_adapter)
7113
+
5612
7114
  nps_service = NPSService(nps_repo, ticket_repo)
5613
7115
 
5614
7116
  # Create critic service if enabled
@@ -5621,7 +7123,11 @@ class SolanaAgentFactory:
5621
7123
  ticket_repo, llm_adapter, agent_service
5622
7124
  )
5623
7125
 
5624
- notification_service = NotificationService(human_agent_repo)
7126
+ notification_service = NotificationService(
7127
+ human_agent_registry=human_agent_repo,
7128
+ tool_registry=agent_service.tool_registry
7129
+ )
7130
+
5625
7131
  project_approval_service = ProjectApprovalService(
5626
7132
  ticket_repo, human_agent_repo, notification_service
5627
7133
  )
@@ -5647,6 +7153,27 @@ class SolanaAgentFactory:
5647
7153
  loaded_plugins = agent_service.plugin_manager.load_all_plugins()
5648
7154
  print(f"Loaded {loaded_plugins} plugins")
5649
7155
 
7156
+ # Get list of all agents defined in config
7157
+ config_defined_agents = [agent["name"]
7158
+ for agent in config.get("ai_agents", [])]
7159
+
7160
+ # Sync MongoDB with config-defined agents (delete any agents not in config)
7161
+ all_db_agents = ai_agent_repo.db.find(ai_agent_repo.collection, {})
7162
+ db_agent_names = [agent["name"] for agent in all_db_agents]
7163
+
7164
+ # Find agents that exist in DB but not in config
7165
+ agents_to_delete = [
7166
+ name for name in db_agent_names if name not in config_defined_agents]
7167
+
7168
+ # Delete those agents
7169
+ for agent_name in agents_to_delete:
7170
+ print(
7171
+ f"Deleting agent '{agent_name}' from MongoDB - no longer defined in config")
7172
+ ai_agent_repo.db.delete_one(
7173
+ ai_agent_repo.collection, {"name": agent_name})
7174
+ if agent_name in ai_agent_repo.ai_agents_cache:
7175
+ del ai_agent_repo.ai_agents_cache[agent_name]
7176
+
5650
7177
  # Register predefined agents if any
5651
7178
  for agent_config in config.get("ai_agents", []):
5652
7179
  agent_service.register_ai_agent(
@@ -5659,10 +7186,15 @@ class SolanaAgentFactory:
5659
7186
  # Register tools for this agent if specified
5660
7187
  if "tools" in agent_config:
5661
7188
  for tool_name in agent_config["tools"]:
7189
+ # Print available tools before registering
7190
+ print(
7191
+ f"Available tools before registering {tool_name}: {agent_service.tool_registry.list_all_tools()}")
5662
7192
  try:
5663
7193
  agent_service.register_tool_for_agent(
5664
7194
  agent_config["name"], tool_name
5665
7195
  )
7196
+ print(
7197
+ f"Successfully registered {tool_name} for agent {agent_config['name']}")
5666
7198
  except ValueError as e:
5667
7199
  print(
5668
7200
  f"Error registering tool {tool_name} for agent {agent_config['name']}: {e}"
@@ -6340,51 +7872,57 @@ class SolanaAgent:
6340
7872
  # PLUGIN SYSTEM
6341
7873
  #############################################
6342
7874
 
7875
+ class AutoTool:
7876
+ """Base class for tools that automatically register with the system."""
6343
7877
 
6344
- class Tool(ABC):
6345
- """Base class for all agent tools."""
7878
+ def __init__(self, name: str, description: str, registry=None):
7879
+ """Initialize the tool with name and description."""
7880
+ self.name = name
7881
+ self.description = description
7882
+ self._config = {}
6346
7883
 
6347
- @property
6348
- @abstractmethod
6349
- def name(self) -> str:
6350
- """Unique name of the tool."""
6351
- pass
7884
+ # Register with the provided registry if given
7885
+ if registry is not None:
7886
+ registry.register_tool(self)
6352
7887
 
6353
- @property
6354
- @abstractmethod
6355
- def description(self) -> str:
6356
- """Human-readable description of what the tool does."""
6357
- pass
7888
+ def configure(self, config: Dict[str, Any]) -> None:
7889
+ """Configure the tool with settings from config."""
7890
+ self._config = config
6358
7891
 
6359
- @property
6360
- @abstractmethod
6361
- def parameters_schema(self) -> Dict[str, Any]:
6362
- """JSON Schema for tool parameters."""
6363
- pass
7892
+ def get_schema(self) -> Dict[str, Any]:
7893
+ """Return the JSON schema for this tool's parameters."""
7894
+ # Override in subclasses
7895
+ return {}
6364
7896
 
6365
- @abstractmethod
6366
- def execute(self, **kwargs) -> Dict[str, Any]:
6367
- """Execute the tool with provided parameters."""
6368
- pass
7897
+ def execute(self, **params) -> Dict[str, Any]:
7898
+ """Execute the tool with the provided parameters."""
7899
+ # Override in subclasses
7900
+ raise NotImplementedError()
6369
7901
 
6370
7902
 
6371
7903
  class ToolRegistry:
6372
- """Central registry for all available tools."""
7904
+ """Instance-based registry that manages tools and their access permissions."""
6373
7905
 
6374
7906
  def __init__(self):
6375
- self._tools: Dict[str, Type[Tool]] = {}
6376
- # agent_name -> [tool_names]
6377
- self._agent_tools: Dict[str, List[str]] = {}
7907
+ """Initialize an empty tool registry."""
7908
+ self._tools = {} # name -> tool instance
7909
+ self._agent_tools = {} # agent_name -> [tool_names]
7910
+
7911
+ def register_tool(self, tool) -> bool:
7912
+ """Register a tool with this registry."""
7913
+ self._tools[tool.name] = tool
7914
+ print(f"Registered tool: {tool.name}")
7915
+ return True
6378
7916
 
6379
- def register_tool(self, tool_class: Type[Tool]) -> None:
6380
- """Register a tool in the global registry."""
6381
- instance = tool_class()
6382
- self._tools[instance.name] = tool_class
7917
+ def get_tool(self, tool_name: str):
7918
+ """Get a tool by name."""
7919
+ return self._tools.get(tool_name)
6383
7920
 
6384
- def assign_tool_to_agent(self, agent_name: str, tool_name: str) -> None:
6385
- """Grant an agent access to a specific tool."""
7921
+ def assign_tool_to_agent(self, agent_name: str, tool_name: str) -> bool:
7922
+ """Give an agent access to a specific tool."""
6386
7923
  if tool_name not in self._tools:
6387
- raise ValueError(f"Tool {tool_name} is not registered")
7924
+ print(f"Error: Tool {tool_name} is not registered")
7925
+ return False
6388
7926
 
6389
7927
  if agent_name not in self._agent_tools:
6390
7928
  self._agent_tools[agent_name] = []
@@ -6392,83 +7930,86 @@ class ToolRegistry:
6392
7930
  if tool_name not in self._agent_tools[agent_name]:
6393
7931
  self._agent_tools[agent_name].append(tool_name)
6394
7932
 
7933
+ return True
7934
+
6395
7935
  def get_agent_tools(self, agent_name: str) -> List[Dict[str, Any]]:
6396
- """Get all tools available to a specific agent."""
7936
+ """Get all tools available to an agent."""
6397
7937
  tool_names = self._agent_tools.get(agent_name, [])
6398
- tool_defs = []
6399
-
6400
- for name in tool_names:
6401
- if name in self._tools:
6402
- tool_instance = self._tools[name]()
6403
- tool_defs.append(
6404
- {
6405
- "name": tool_instance.name,
6406
- "description": tool_instance.description,
6407
- "parameters": tool_instance.parameters_schema,
6408
- }
6409
- )
6410
-
6411
- return tool_defs
6412
-
6413
- def get_tool(self, tool_name: str) -> Optional[Tool]:
6414
- """Get a tool by name."""
6415
- tool_class = self._tools.get(tool_name)
6416
- return tool_class() if tool_class else None
7938
+ return [
7939
+ {
7940
+ "name": name,
7941
+ "description": self._tools[name].description,
7942
+ "parameters": self._tools[name].get_schema()
7943
+ }
7944
+ for name in tool_names if name in self._tools
7945
+ ]
6417
7946
 
6418
7947
  def list_all_tools(self) -> List[str]:
6419
- """List all registered tool names."""
7948
+ """List all registered tools."""
6420
7949
  return list(self._tools.keys())
6421
7950
 
6422
-
6423
- # Global registry instance
6424
- tool_registry = ToolRegistry()
7951
+ def configure_all_tools(self, config: Dict[str, Any]) -> None:
7952
+ """Configure all registered tools with the same config."""
7953
+ for tool in self._tools.values():
7954
+ tool.configure(config)
6425
7955
 
6426
7956
 
6427
7957
  class PluginManager:
6428
- """Manages discovery, loading and execution of plugins."""
7958
+ """Manager for discovering and loading plugins."""
6429
7959
 
6430
- def __init__(self):
6431
- self.tools = {}
7960
+ # Class variable to track loaded entry points
7961
+ _loaded_entry_points = set()
6432
7962
 
6433
- def load_all_plugins(self) -> int:
6434
- """Load all plugins using setuptools entry points.
7963
+ def __init__(self, config: Optional[Dict[str, Any]] = None, tool_registry: Optional[ToolRegistry] = None):
7964
+ """Initialize with optional configuration and tool registry."""
7965
+ self.config = config or {}
7966
+ self.tool_registry = tool_registry or ToolRegistry()
6435
7967
 
6436
- Returns the number of plugins loaded for backwards compatibility.
6437
- """
6438
- import importlib.metadata
7968
+ def load_all_plugins(self) -> int:
7969
+ """Load all plugins using entry points and apply configuration."""
7970
+ loaded_count = 0
7971
+ plugins = []
6439
7972
 
6440
- count = 0
6441
- # Discover plugins registered via entry_points
7973
+ # Discover plugins through entry points
6442
7974
  for entry_point in importlib.metadata.entry_points(group='solana_agent.plugins'):
7975
+ # Skip if this entry point has already been loaded
7976
+ entry_point_id = f"{entry_point.name}:{entry_point.value}"
7977
+ if entry_point_id in PluginManager._loaded_entry_points:
7978
+ print(f"Skipping already loaded plugin: {entry_point.name}")
7979
+ continue
7980
+
6443
7981
  try:
6444
- plugin_class = entry_point.load()
6445
- plugin = plugin_class()
6446
-
6447
- # Register all tools from this plugin
6448
- for tool in plugin.get_tools():
6449
- self.tools[tool.name] = tool
6450
- print(f"Registered tool: {tool.name}")
6451
- count += 1
7982
+ print(f"Found plugin entry point: {entry_point.name}")
7983
+ PluginManager._loaded_entry_points.add(entry_point_id)
7984
+ plugin_factory = entry_point.load()
7985
+ plugin = plugin_factory()
7986
+ plugins.append(plugin)
7987
+
7988
+ # Initialize the plugin with config
7989
+ if hasattr(plugin, 'initialize') and callable(plugin.initialize):
7990
+ plugin.initialize(self.config)
7991
+ print(
7992
+ f"Initialized plugin {entry_point.name} with config keys: {list(self.config.keys() if self.config else [])}")
7993
+
7994
+ loaded_count += 1
6452
7995
  except Exception as e:
6453
7996
  print(f"Error loading plugin {entry_point.name}: {e}")
6454
7997
 
6455
- return count
6456
-
6457
- def get_tool(self, name):
6458
- """Get a tool by name."""
6459
- return self.tools.get(name)
6460
-
6461
- def list_tools(self):
6462
- """List all available tools."""
6463
- return list(self.tools.keys())
6464
-
6465
- def execute_tool(self, tool_name: str, **kwargs) -> Dict[str, Any]:
6466
- """Execute a tool with provided parameters."""
6467
- tool = self.tools.get(tool_name)
6468
- if not tool:
6469
- raise ValueError(f"Tool {tool_name} not found")
7998
+ # After all plugins are initialized, register their tools
7999
+ for plugin in plugins:
8000
+ try:
8001
+ if hasattr(plugin, 'get_tools') and callable(plugin.get_tools):
8002
+ tools = plugin.get_tools()
8003
+ # Register each tool with our registry
8004
+ if isinstance(tools, list):
8005
+ for tool in tools:
8006
+ self.tool_registry.register_tool(tool)
8007
+ tool.configure(self.config)
8008
+ else:
8009
+ # Single tool case
8010
+ self.tool_registry.register_tool(tools)
8011
+ tools.configure(self.config)
8012
+ except Exception as e:
8013
+ print(f"Error registering tools from plugin: {e}")
6470
8014
 
6471
- try:
6472
- return tool.execute(**kwargs)
6473
- except Exception as e:
6474
- return {"error": str(e), "status": "error"}
8015
+ return loaded_count