solana-agent 6.0.1__py3-none-any.whl → 8.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
@@ -1,12 +1,13 @@
1
1
  import asyncio
2
2
  import datetime
3
+ import re
3
4
  import traceback
4
5
  import ntplib
5
6
  import json
6
7
  from typing import AsyncGenerator, List, Literal, Dict, Any, Callable
7
8
  import uuid
8
9
  import pandas as pd
9
- from pydantic import BaseModel
10
+ from pydantic import BaseModel, Field
10
11
  from pymongo import MongoClient
11
12
  from openai import OpenAI
12
13
  import inspect
@@ -20,6 +21,43 @@ from zep_cloud.types import Message
20
21
  from pinecone import Pinecone
21
22
 
22
23
 
24
+ # Define Pydantic models for structured output
25
+ class ImprovementArea(BaseModel):
26
+ area: str = Field(...,
27
+ description="Area name (e.g., 'Accuracy', 'Completeness')")
28
+ issue: str = Field(..., description="Specific issue identified")
29
+ recommendation: str = Field(...,
30
+ description="Specific actionable improvement")
31
+
32
+
33
+ class CritiqueFeedback(BaseModel):
34
+ strengths: List[str] = Field(
35
+ default_factory=list, description="List of strengths in the response"
36
+ )
37
+ improvement_areas: List[ImprovementArea] = Field(
38
+ default_factory=list, description="Areas needing improvement"
39
+ )
40
+ overall_score: float = Field(..., description="Score between 0.0 and 1.0")
41
+ priority: Literal["low", "medium", "high"] = Field(
42
+ ..., description="Priority level for improvements"
43
+ )
44
+
45
+
46
+ class MemoryInsight(BaseModel):
47
+ fact: str = Field(...,
48
+ description="The factual information worth remembering")
49
+ relevance: str = Field(
50
+ ..., description="Short explanation of why this fact is generally useful"
51
+ )
52
+
53
+
54
+ class CollectiveMemoryResponse(BaseModel):
55
+ insights: List[MemoryInsight] = Field(
56
+ default_factory=list,
57
+ description="List of factual insights extracted from the conversation",
58
+ )
59
+
60
+
23
61
  class DocumentModel(BaseModel):
24
62
  id: str
25
63
  text: str
@@ -31,6 +69,7 @@ class MongoDatabase:
31
69
  self.db = self._client[db_name]
32
70
  self.messages = self.db["messages"]
33
71
  self.kb = self.db["kb"]
72
+ self.jobs = self.db["jobs"]
34
73
 
35
74
  def save_message(self, user_id: str, metadata: Dict[str, Any]):
36
75
  metadata["user_id"] = user_id
@@ -70,9 +109,11 @@ class AI:
70
109
  pinecone_index_name: str = None,
71
110
  pinecone_embed_model: Literal["llama-text-embed-v2"] = "llama-text-embed-v2",
72
111
  gemini_api_key: str = None,
73
- openai_base_url: str = None,
74
112
  tool_calling_model: str = "gpt-4o-mini",
75
113
  reasoning_model: str = "gpt-4o-mini",
114
+ research_model: str = "gpt-4o-mini",
115
+ enable_internet_search: bool = True,
116
+ default_timezone: str = "UTC",
76
117
  ):
77
118
  """Initialize a new AI assistant instance.
78
119
 
@@ -88,9 +129,11 @@ class AI:
88
129
  pinecone_index_name (str, optional): Name of the Pinecone index. Defaults to None
89
130
  pinecone_embed_model (Literal["llama-text-embed-v2"], optional): Pinecone embedding model. Defaults to "llama-text-embed-v2"
90
131
  gemini_api_key (str, optional): API key for Gemini search. Defaults to None
91
- openai_base_url (str, optional): Base URL for OpenAI API. Defaults to None
92
132
  tool_calling_model (str, optional): Model for tool calling. Defaults to "gpt-4o-mini"
93
133
  reasoning_model (str, optional): Model for reasoning. Defaults to "gpt-4o-mini"
134
+ research_model (str, optional): Model for research. Defaults to "gpt-4o-mini"
135
+ enable_internet_search (bool, optional): Enable internet search tools. Defaults to True
136
+ default_timezone (str, optional): Default timezone for time awareness. Defaults to "UTC"
94
137
  Example:
95
138
  ```python
96
139
  ai = AI(
@@ -107,11 +150,7 @@ class AI:
107
150
  - Optional integrations for Perplexity, Pinecone, Gemini, and Grok
108
151
  - You must create the Pinecone index in the dashboard before using it
109
152
  """
110
- self._client = (
111
- OpenAI(api_key=openai_api_key, base_url=openai_base_url)
112
- if openai_base_url
113
- else OpenAI(api_key=openai_api_key)
114
- )
153
+ self._client = OpenAI(api_key=openai_api_key)
115
154
  self._memory_instructions = """
116
155
  You are a highly intelligent, context-aware conversational AI. When a user sends a query or statement, you should not only process the current input but also retrieve and integrate relevant context from their previous interactions. Use the memory data to:
117
156
  - Infer nuances in the user's intent.
@@ -147,10 +186,89 @@ class AI:
147
186
  self._pinecone.Index(
148
187
  self._pinecone_index_name) if self._pinecone else None
149
188
  )
150
- self._openai_base_url = openai_base_url
151
189
  self._tool_calling_model = tool_calling_model
152
190
  self._reasoning_model = reasoning_model
191
+ self._research_model = research_model
153
192
  self._tools = []
193
+ self._job_processor_task = None
194
+ self._default_timezone = default_timezone
195
+
196
+ # Automatically add internet search tool if API key is provided and feature is enabled
197
+ if perplexity_api_key and enable_internet_search:
198
+ # Use the add_tool decorator functionality directly
199
+ search_internet_tool = {
200
+ "type": "function",
201
+ "function": {
202
+ "name": "search_internet",
203
+ "description": "Search the internet using Perplexity AI API",
204
+ "parameters": {
205
+ "type": "object",
206
+ "properties": {
207
+ "query": {
208
+ "type": "string",
209
+ "description": "Search query string",
210
+ },
211
+ "model": {
212
+ "type": "string",
213
+ "description": "Perplexity model to use",
214
+ "enum": [
215
+ "sonar",
216
+ "sonar-pro",
217
+ "sonar-reasoning-pro",
218
+ "sonar-reasoning",
219
+ ],
220
+ "default": "sonar",
221
+ },
222
+ },
223
+ "required": ["query"],
224
+ },
225
+ },
226
+ }
227
+ self._tools.append(search_internet_tool)
228
+ print("Internet search capability added as default tool")
229
+
230
+ # Automatically add knowledge base search tool if Pinecone is configured
231
+ if pinecone_api_key and pinecone_index_name:
232
+ search_kb_tool = {
233
+ "type": "function",
234
+ "function": {
235
+ "name": "search_kb",
236
+ "description": "Search the knowledge base using Pinecone",
237
+ "parameters": {
238
+ "type": "object",
239
+ "properties": {
240
+ "query": {
241
+ "type": "string",
242
+ "description": "Search query to find relevant documents",
243
+ },
244
+ "namespace": {
245
+ "type": "string",
246
+ "description": "Namespace of the Pinecone to search",
247
+ "default": "global",
248
+ },
249
+ "rerank_model": {
250
+ "type": "string",
251
+ "description": "Rerank model to use",
252
+ "enum": ["cohere-rerank-3.5"],
253
+ "default": "cohere-rerank-3.5",
254
+ },
255
+ "inner_limit": {
256
+ "type": "integer",
257
+ "description": "Maximum number of results to rerank",
258
+ "default": 10,
259
+ },
260
+ "limit": {
261
+ "type": "integer",
262
+ "description": "Maximum number of results to return",
263
+ "default": 3,
264
+ },
265
+ },
266
+ "required": ["query"],
267
+ },
268
+ },
269
+ }
270
+ self._tools.append(search_kb_tool)
271
+ print("Knowledge base search capability added as default tool")
154
272
 
155
273
  async def __aenter__(self):
156
274
  return self
@@ -440,25 +558,19 @@ class AI:
440
558
  self.kb.delete(ids=[id], namespace=user_id)
441
559
  self._database.kb.delete_one({"reference": id})
442
560
 
443
- def check_time(self, timezone: str) -> str:
444
- """Get current UTC time formatted as a string via Cloudflare's NTP service.
561
+ def check_time(self, timezone: str = None) -> str:
562
+ """Get current time in requested timezone as a string.
445
563
 
446
564
  Args:
447
- timezone (str): Timezone to convert the time to (e.g., "America/New_York")
565
+ timezone (str, optional): Timezone to convert the time to (e.g., "America/New_York").
566
+ If None, uses the agent's default timezone.
448
567
 
449
568
  Returns:
450
569
  str: Current time in the requested timezone in format 'YYYY-MM-DD HH:MM:SS'
451
-
452
- Example:
453
- ```python
454
- time = ai.check_time("America/New_York")
455
- # Returns: "The current time in America/New_York is 2025-02-26 10:30:45"
456
- ```
457
-
458
- Note:
459
- This is a synchronous tool method required for OpenAI function calling.
460
- Fetches time over NTP from Cloudflare's time server (time.cloudflare.com).
461
570
  """
571
+ # Use provided timezone or fall back to agent default
572
+ timezone = timezone or self._default_timezone or "UTC"
573
+
462
574
  try:
463
575
  # Request time from Cloudflare's NTP server
464
576
  client = ntplib.NTPClient()
@@ -474,7 +586,10 @@ class AI:
474
586
  tz = pytz.timezone(timezone)
475
587
  local_dt = utc_dt.astimezone(tz)
476
588
  formatted_time = local_dt.strftime("%Y-%m-%d %H:%M:%S")
477
- return f"The current time in {timezone} is {formatted_time}"
589
+
590
+ # Format exactly as the test expects
591
+ return f"current time in {timezone} is {formatted_time}"
592
+
478
593
  except pytz.exceptions.UnknownTimeZoneError:
479
594
  return f"Error: Unknown timezone '{timezone}'. Please use a valid timezone like 'America/New_York'."
480
595
 
@@ -648,6 +763,102 @@ class AI:
648
763
  except Exception:
649
764
  pass
650
765
 
766
+ def make_time_aware(self, default_timezone="UTC"):
767
+ """Make the agent time-aware by adding time checking capability."""
768
+ # Add time awareness to instructions with explicit formatting guidance
769
+ time_instructions = f"""
770
+ IMPORTANT: You are time-aware. The current date is {datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")}.
771
+
772
+ TIME RESPONSE RULES:
773
+ 1. When asked about the current time, ONLY use the check_time tool and respond with EXACTLY what it returns
774
+ 2. NEVER add UTC time when the check_time tool returns local time
775
+ 3. NEVER convert between timezones unless explicitly requested
776
+ 4. NEVER mention timezone offsets (like "X hours behind UTC") unless explicitly asked
777
+ 5. Local time is the ONLY time that should be mentioned in your response
778
+
779
+ Default timezone: {default_timezone} (use this when user's timezone is unknown)
780
+ """
781
+ self._instructions = self._instructions + "\n\n" + time_instructions
782
+
783
+ self._default_timezone = default_timezone
784
+
785
+ # Ensure the check_time tool is registered (in case it was removed)
786
+ existing_tools = [t["function"]["name"] for t in self._tools]
787
+ if "check_time" not in existing_tools:
788
+ # Get method reference
789
+ check_time_func = self.check_time
790
+ # Re-register it using our add_tool decorator
791
+ self.add_tool(check_time_func)
792
+
793
+ return self
794
+
795
+ async def research_and_learn(self, topic: str) -> str:
796
+ """Research a topic and add findings to collective memory.
797
+
798
+ Args:
799
+ topic: The topic to research and learn about
800
+
801
+ Returns:
802
+ Summary of what was learned
803
+ """
804
+ try:
805
+ # First, search the internet for information
806
+ search_results = await self.search_internet(
807
+ f"comprehensive information about {topic}"
808
+ )
809
+
810
+ # Extract structured knowledge
811
+ prompt = f"""
812
+ Based on these search results about "{topic}", extract 3-5 factual insights
813
+ worth adding to our collective knowledge.
814
+
815
+ Search results:
816
+ {search_results}
817
+
818
+ Format each insight as a JSON object with:
819
+ 1. "fact": The factual information
820
+ 2. "relevance": Short explanation of why this is generally useful
821
+
822
+ Return ONLY a valid JSON array. Example:
823
+ [
824
+ {{"fact": "Topic X has property Y",
825
+ "relevance": "Important for understanding Z"}}
826
+ ]
827
+ """
828
+
829
+ response = self._client.chat.completions.create(
830
+ model=self._research_model,
831
+ messages=[
832
+ {
833
+ "role": "system",
834
+ "content": "Extract factual knowledge from research.",
835
+ },
836
+ {"role": "user", "content": prompt},
837
+ ],
838
+ temperature=0.1,
839
+ )
840
+
841
+ insights = json.loads(response.choices[0].message.content)
842
+
843
+ # Add to collective memory via the swarm
844
+ if hasattr(self, "_swarm") and self._swarm and insights:
845
+ conversation = {
846
+ "message": f"Research on {topic}",
847
+ "response": json.dumps(insights),
848
+ "user_id": "system_explorer",
849
+ }
850
+ await self._swarm.extract_and_store_insights(
851
+ "system_explorer", conversation
852
+ )
853
+
854
+ # Return a summary of what was learned
855
+ return f"✅ Added {len(insights)} new insights about '{topic}' to collective memory."
856
+
857
+ return "⚠️ Could not add insights to collective memory."
858
+
859
+ except Exception as e:
860
+ return f"❌ Error researching topic: {str(e)}"
861
+
651
862
  async def delete_memory(self, user_id: str):
652
863
  """Delete memory for a specific user from Zep memory.
653
864
 
@@ -676,12 +887,20 @@ class AI:
676
887
  )
677
888
  return transcription.text
678
889
 
679
- async def text(self, user_id: str, user_text: str) -> AsyncGenerator[str, None]:
890
+ async def text(
891
+ self,
892
+ user_id: str,
893
+ user_text: str,
894
+ timezone: str = None,
895
+ original_user_text: str = None,
896
+ ) -> AsyncGenerator[str, None]:
680
897
  """Process text input and stream AI responses asynchronously.
681
898
 
682
899
  Args:
683
900
  user_id (str): Unique identifier for the user/conversation.
684
901
  user_text (str): Text input from user to process.
902
+ original_user_text (str, optional): Original user message for storage. If provided,
903
+ this will be stored instead of user_text. Defaults to None.
685
904
 
686
905
  Returns:
687
906
  AsyncGenerator[str, None]: Stream of response text chunks (including tool call results).
@@ -698,6 +917,22 @@ class AI:
698
917
  - Integrates with Zep memory if configured.
699
918
  - Supports tool calls by aggregating and executing them as their arguments stream in.
700
919
  """
920
+ # Store current user ID for task scheduling context
921
+ self._current_user_id = user_id
922
+
923
+ # Store timezone with user ID for persistence
924
+ if timezone:
925
+ if not hasattr(self, "_user_timezones"):
926
+ self._user_timezones = {}
927
+ self._user_timezones[user_id] = timezone
928
+
929
+ # Set current timezone for this session
930
+ self._current_timezone = (
931
+ timezone
932
+ if timezone
933
+ else self._user_timezones.get(user_id, self._default_timezone)
934
+ )
935
+
701
936
  self._accumulated_value_queue = asyncio.Queue()
702
937
  final_tool_calls = {} # Accumulate tool call deltas
703
938
  final_response = ""
@@ -829,10 +1064,15 @@ class AI:
829
1064
  if self._accumulated_value_queue.empty():
830
1065
  break
831
1066
 
1067
+ # For storage purposes, use original text if provided
1068
+ message_to_store = (
1069
+ original_user_text if original_user_text is not None else user_text
1070
+ )
1071
+
832
1072
  # Save the conversation to the database and Zep memory (if configured)
833
1073
  metadata = {
834
1074
  "user_id": user_id,
835
- "message": user_text,
1075
+ "message": message_to_store,
836
1076
  "response": final_response,
837
1077
  "timestamp": datetime.datetime.now(datetime.timezone.utc),
838
1078
  }
@@ -1107,35 +1347,819 @@ class AI:
1107
1347
  return func
1108
1348
 
1109
1349
 
1350
+ class HumanAgent:
1351
+ """Represents a human operator in the agent swarm."""
1352
+
1353
+ def __init__(
1354
+ self,
1355
+ agent_id: str,
1356
+ name: str,
1357
+ specialization: str,
1358
+ notification_handler: Callable = None,
1359
+ availability_status: Literal["available",
1360
+ "busy", "offline"] = "available",
1361
+ ):
1362
+ """Initialize a human agent.
1363
+
1364
+ Args:
1365
+ agent_id (str): Unique identifier for this human agent
1366
+ name (str): Display name of the human agent
1367
+ specialization (str): Area of expertise description
1368
+ notification_handler (Callable, optional): Function to call when agent receives a handoff
1369
+ availability_status (str): Current availability of the human agent
1370
+ """
1371
+ self.agent_id = agent_id
1372
+ self.name = name
1373
+ self.specialization = specialization
1374
+ self.notification_handler = notification_handler
1375
+ self.availability_status = availability_status
1376
+ self.current_tickets = {} # Tracks tickets assigned to this human
1377
+
1378
+ async def receive_handoff(
1379
+ self, ticket_id: str, user_id: str, query: str, context: str
1380
+ ) -> bool:
1381
+ """Handle receiving a ticket from an AI agent or another human.
1382
+
1383
+ Args:
1384
+ ticket_id: Unique identifier for this conversation thread
1385
+ user_id: End user identifier
1386
+ query: The user's question or issue
1387
+ context: Conversation context and history
1388
+
1389
+ Returns:
1390
+ bool: Whether the handoff was accepted
1391
+ """
1392
+ if self.availability_status != "available":
1393
+ return False
1394
+
1395
+ # Add to current tickets
1396
+ self.current_tickets[ticket_id] = {
1397
+ "user_id": user_id,
1398
+ "query": query,
1399
+ "context": context,
1400
+ "status": "pending",
1401
+ "received_at": datetime.datetime.now(datetime.timezone.utc),
1402
+ }
1403
+
1404
+ # Notify the human operator through the configured handler
1405
+ if self.notification_handler:
1406
+ await self.notification_handler(
1407
+ agent_id=self.agent_id,
1408
+ ticket_id=ticket_id,
1409
+ user_id=user_id,
1410
+ query=query,
1411
+ context=context,
1412
+ )
1413
+
1414
+ return True
1415
+
1416
+ async def respond(self, ticket_id: str, response: str) -> Dict[str, Any]:
1417
+ """Submit a response to a user query.
1418
+
1419
+ Args:
1420
+ ticket_id: The ticket identifier
1421
+ response: The human agent's response text
1422
+
1423
+ Returns:
1424
+ Dict with response details and status
1425
+ """
1426
+ if ticket_id not in self.current_tickets:
1427
+ return {"status": "error", "message": "Ticket not found"}
1428
+
1429
+ ticket = self.current_tickets[ticket_id]
1430
+ ticket["response"] = response
1431
+ ticket["response_time"] = datetime.datetime.now(datetime.timezone.utc)
1432
+ ticket["status"] = "responded"
1433
+
1434
+ return {
1435
+ "status": "success",
1436
+ "ticket_id": ticket_id,
1437
+ "user_id": ticket["user_id"],
1438
+ "response": response,
1439
+ }
1440
+
1441
+ async def handoff_to(
1442
+ self, ticket_id: str, target_agent_id: str, reason: str
1443
+ ) -> bool:
1444
+ """Hand off a ticket to another agent (AI or human).
1445
+
1446
+ Args:
1447
+ ticket_id: The ticket to hand off
1448
+ target_agent_id: Agent to transfer the ticket to
1449
+ reason: Reason for the handoff
1450
+
1451
+ Returns:
1452
+ bool: Whether handoff was successful
1453
+ """
1454
+ if ticket_id not in self.current_tickets:
1455
+ return False
1456
+
1457
+ # This just marks it for handoff - the actual handoff is handled by the swarm
1458
+ self.current_tickets[ticket_id]["status"] = "handoff_requested"
1459
+ self.current_tickets[ticket_id]["handoff_target"] = target_agent_id
1460
+ self.current_tickets[ticket_id]["handoff_reason"] = reason
1461
+
1462
+ return True
1463
+
1464
+ def update_availability(
1465
+ self, status: Literal["available", "busy", "offline"]
1466
+ ) -> None:
1467
+ """Update the availability status of this human agent."""
1468
+ self.availability_status = status
1469
+
1470
+ async def check_pending_tickets(self) -> str:
1471
+ """Get a summary of pending tickets for this human agent."""
1472
+ if not self.current_tickets:
1473
+ return "You have no pending tickets."
1474
+
1475
+ pending_count = sum(
1476
+ 1 for t in self.current_tickets.values() if t["status"] == "pending"
1477
+ )
1478
+ result = [f"You have {pending_count} pending tickets"]
1479
+
1480
+ # Add details for each pending ticket
1481
+ for ticket_id, ticket in self.current_tickets.items():
1482
+ if ticket["status"] == "pending":
1483
+ received_time = ticket.get(
1484
+ "received_at", datetime.datetime.now(datetime.timezone.utc)
1485
+ )
1486
+ time_diff = datetime.datetime.now(
1487
+ datetime.timezone.utc) - received_time
1488
+ hours_ago = round(time_diff.total_seconds() / 3600, 1)
1489
+
1490
+ result.append(
1491
+ f"- Ticket {ticket_id[:8]}... from user {ticket['user_id'][:8]}... ({hours_ago}h ago)"
1492
+ )
1493
+ result.append(
1494
+ f" Query: {ticket['query'][:50]}..."
1495
+ if len(ticket["query"]) > 50
1496
+ else f" Query: {ticket['query']}"
1497
+ )
1498
+
1499
+ if pending_count == 0:
1500
+ result.append("No pending tickets requiring your attention.")
1501
+
1502
+ return "\n".join(result)
1503
+
1504
+
1110
1505
  class Swarm:
1111
1506
  """An AI Agent Swarm that coordinates specialized AI agents with handoff capabilities."""
1112
1507
 
1113
- def __init__(self, database: MongoDatabase, router_model: str = "gpt-4o"):
1508
+ def __init__(
1509
+ self,
1510
+ database: MongoDatabase,
1511
+ directive: str = None,
1512
+ router_model: str = "gpt-4o-mini",
1513
+ insight_model: str = "gpt-4o-mini",
1514
+ enable_collective_memory: bool = True,
1515
+ enable_critic: bool = True,
1516
+ default_timezone: str = "UTC",
1517
+ ):
1114
1518
  """Initialize the multi-agent system with a shared database.
1115
1519
 
1116
1520
  Args:
1117
1521
  database (MongoDatabase): Shared MongoDB database instance
1118
- router_model (str, optional): Model to use for routing decisions. Defaults to "gpt-4o".
1522
+ directive (str, optional): Core directive/mission that governs all agents. Defaults to None.
1523
+ router_model (str, optional): Model to use for routing decisions. Defaults to "gpt-4o-mini".
1524
+ insight_model (str, optional): Model to extract collective insights. Defaults to "gpt-4o-mini".
1525
+ enable_collective_memory (bool, optional): Whether to enable collective memory. Defaults to True.
1526
+ enable_critic (bool, optional): Whether to enable the critic system. Defaults to True.
1527
+ default_timezone (str, optional): Default timezone for time-awareness. Defaults to "UTC".
1119
1528
  """
1120
1529
  self.agents = {} # name -> AI instance
1121
1530
  self.specializations = {} # name -> description
1122
1531
  self.database = database
1123
1532
  self.router_model = router_model
1533
+ self.insight_model = insight_model
1534
+ self.enable_collective_memory = enable_collective_memory
1535
+ self.default_timezone = default_timezone
1536
+ self.enable_critic = enable_critic
1537
+
1538
+ # Store swarm directive
1539
+ self.swarm_directive = (
1540
+ directive
1541
+ or """
1542
+ You are part of an agent swarm that works together to serve users effectively.
1543
+ Your goals are to provide accurate, helpful responses while collaborating with other agents.
1544
+ """
1545
+ )
1546
+
1547
+ self.formatted_directive = f"""
1548
+ ┌─────────────── SWARM DIRECTIVE ───────────────┐
1549
+ {self.swarm_directive}
1550
+ └─────────────────────────────────────────────┘
1551
+ """
1552
+
1553
+ # Initialize critic if enabled
1554
+ if enable_critic:
1555
+ self.critic = Critic(
1556
+ self,
1557
+ critique_model=insight_model,
1558
+ )
1124
1559
 
1125
1560
  # Ensure handoffs collection exists
1126
1561
  if "handoffs" not in self.database.db.list_collection_names():
1127
1562
  self.database.db.create_collection("handoffs")
1128
1563
  self.handoffs = self.database.db["handoffs"]
1129
1564
 
1565
+ # Create collective memory collection
1566
+ if enable_collective_memory:
1567
+ if "collective_memory" not in self.database.db.list_collection_names():
1568
+ self.database.db.create_collection("collective_memory")
1569
+ self.collective_memory = self.database.db["collective_memory"]
1570
+
1571
+ # Create text index for MongoDB text search
1572
+ try:
1573
+ self.collective_memory.create_index(
1574
+ [("fact", "text"), ("relevance", "text")]
1575
+ )
1576
+ print("Created text search index for collective memory")
1577
+ except Exception as e:
1578
+ print(f"Warning: Text index creation might have failed: {e}")
1579
+ else:
1580
+ print("Collective memory feature is disabled")
1581
+
1130
1582
  print(
1131
1583
  f"MultiAgentSystem initialized with router model: {router_model}")
1132
1584
 
1585
+ def register_human_agent(
1586
+ self,
1587
+ agent_id: str,
1588
+ name: str,
1589
+ specialization: str,
1590
+ notification_handler: Callable = None,
1591
+ ) -> HumanAgent:
1592
+ """Register a human agent with the swarm.
1593
+
1594
+ Args:
1595
+ agent_id: Unique identifier for this human agent
1596
+ name: Display name of the human agent
1597
+ specialization: Description of expertise
1598
+ notification_handler: Function to call when agent receives handoff
1599
+
1600
+ Returns:
1601
+ The created HumanAgent instance
1602
+ """
1603
+ # Create human agent instance
1604
+ human_agent = HumanAgent(
1605
+ agent_id=agent_id,
1606
+ name=name,
1607
+ specialization=specialization,
1608
+ notification_handler=notification_handler,
1609
+ )
1610
+
1611
+ # Store in humans registry
1612
+ if not hasattr(self, "human_agents"):
1613
+ self.human_agents = {}
1614
+ self.human_agents[agent_id] = human_agent
1615
+
1616
+ # Add human agent to specialization map
1617
+ self.specializations[agent_id] = f"[HUMAN] {specialization}"
1618
+
1619
+ # Create or update the ticket collection
1620
+ if "tickets" not in self.database.db.list_collection_names():
1621
+ self.database.db.create_collection("tickets")
1622
+ self.tickets = self.database.db["tickets"]
1623
+
1624
+ print(
1625
+ f"Registered human agent: {name}, specialization: {specialization[:50]}..."
1626
+ )
1627
+
1628
+ # Update AI agents with human handoff capabilities
1629
+ self._update_all_handoff_capabilities()
1630
+
1631
+ return human_agent
1632
+
1633
+ def _update_all_handoff_capabilities(self):
1634
+ """Update all agents with current handoff capabilities for both AI and human agents."""
1635
+ # Get all AI agent names
1636
+ ai_agent_names = list(self.agents.keys())
1637
+
1638
+ # Get all human agent names
1639
+ human_agent_names = (
1640
+ list(self.human_agents.keys()) if hasattr(
1641
+ self, "human_agents") else []
1642
+ )
1643
+
1644
+ # For each AI agent, update its handoff tools
1645
+ for agent_name, agent in self.agents.items():
1646
+ # Get available target agents (both AI and human)
1647
+ available_ai_targets = [
1648
+ name for name in ai_agent_names if name != agent_name
1649
+ ]
1650
+ available_targets = available_ai_targets + human_agent_names
1651
+
1652
+ # First remove any existing handoff tools
1653
+ agent._tools = [
1654
+ t for t in agent._tools if t["function"]["name"] != "request_handoff"
1655
+ ]
1656
+
1657
+ # Create updated handoff tool with both AI and human targets
1658
+ def create_handoff_tool(current_agent_name, available_targets_list):
1659
+ def request_handoff(target_agent: str, reason: str) -> str:
1660
+ """Request an immediate handoff to another agent (AI or human).
1661
+ This is an INTERNAL SYSTEM TOOL. The user will NOT see your reasoning about the handoff.
1662
+ Use this tool IMMEDIATELY when a query is outside your expertise.
1663
+
1664
+ Args:
1665
+ target_agent: Name of agent to transfer to. MUST be one of: {', '.join(available_targets_list)}.
1666
+ DO NOT INVENT NEW NAMES OR VARIATIONS. Use EXACTLY one of these names.
1667
+ reason: Brief explanation of why this question requires the specialist
1668
+
1669
+ Returns:
1670
+ str: Empty string - the handoff is handled internally
1671
+ """
1672
+ # Validate target agent exists (either AI or human)
1673
+ is_human_target = target_agent in human_agent_names
1674
+ is_ai_target = target_agent in ai_agent_names
1675
+
1676
+ if not (is_human_target or is_ai_target):
1677
+ print(
1678
+ f"[HANDOFF WARNING] Invalid target '{target_agent}'")
1679
+ if available_targets_list:
1680
+ original_target = target_agent
1681
+ target_agent = available_targets_list[0]
1682
+ print(
1683
+ f"[HANDOFF CORRECTION] Redirecting from '{original_target}' to '{target_agent}'"
1684
+ )
1685
+ else:
1686
+ print(
1687
+ "[HANDOFF ERROR] No valid target agents available")
1688
+ return ""
1689
+
1690
+ print(
1691
+ f"[HANDOFF TOOL CALLED] {current_agent_name} -> {target_agent}: {reason}"
1692
+ )
1693
+
1694
+ # Set handoff info - now includes flag for whether target is human
1695
+ agent._handoff_info = {
1696
+ "target": target_agent,
1697
+ "reason": reason,
1698
+ "is_human_target": is_human_target,
1699
+ }
1700
+
1701
+ # Return empty string - the actual handoff happens in the process method
1702
+ return ""
1703
+
1704
+ return request_handoff
1705
+
1706
+ # Use the factory to create a properly-bound tool function
1707
+ handoff_tool = create_handoff_tool(agent_name, available_targets)
1708
+
1709
+ # Initialize handoff info attribute
1710
+ agent._handoff_info = None
1711
+
1712
+ # Add the updated handoff tool with proper closure
1713
+ agent.add_tool(handoff_tool)
1714
+
1715
+ # Update agent instructions with handoff guidance including human agents
1716
+ ai_handoff_examples = "\n".join(
1717
+ [
1718
+ f" - `{name}` (AI: {self.specializations[name][:40]}...)"
1719
+ for name in available_ai_targets
1720
+ ]
1721
+ )
1722
+ human_handoff_examples = "\n".join(
1723
+ [
1724
+ f" - `{name}` (HUMAN: {self.specializations[name].replace('[HUMAN] ', '')[:40]}...)"
1725
+ for name in human_agent_names
1726
+ ]
1727
+ )
1728
+
1729
+ handoff_instructions = f"""
1730
+ STRICT HANDOFF GUIDANCE:
1731
+ 1. You must use ONLY the EXACT agent names listed below for handoffs.
1732
+
1733
+ AI AGENTS (available immediately):
1734
+ {ai_handoff_examples}
1735
+
1736
+ HUMAN AGENTS (might have response delay):
1737
+ {human_handoff_examples}
1738
+
1739
+ 2. DO NOT INVENT OR MODIFY AGENT NAMES.
1740
+
1741
+ 3. ONLY these EXACT agent names will work for handoffs: {', '.join(available_targets)}
1742
+
1743
+ 4. Use human agents ONLY when:
1744
+ - The question truly requires human judgment or expertise
1745
+ - The user explicitly asks for a human agent
1746
+ - The task involves confidential information that AI shouldn't access
1747
+ """
1748
+
1749
+ # Update agent instructions with handoff guidance
1750
+ agent._instructions = (
1751
+ re.sub(
1752
+ r"STRICT HANDOFF GUIDANCE:.*?(?=\n\n)",
1753
+ handoff_instructions,
1754
+ agent._instructions,
1755
+ flags=re.DOTALL,
1756
+ )
1757
+ if "STRICT HANDOFF GUIDANCE" in agent._instructions
1758
+ else agent._instructions + "\n\n" + handoff_instructions
1759
+ )
1760
+
1761
+ print("Updated handoff capabilities for all agents with AI and human targets")
1762
+
1763
+ async def process_human_response(
1764
+ self,
1765
+ human_agent_id: str,
1766
+ ticket_id: str,
1767
+ response: str,
1768
+ handoff_to: str = None,
1769
+ handoff_reason: str = None,
1770
+ ) -> Dict[str, Any]:
1771
+ """Process a response from a human agent.
1772
+
1773
+ Args:
1774
+ human_agent_id: ID of the human agent responding
1775
+ ticket_id: Ticket identifier
1776
+ response: Human agent's response text
1777
+ handoff_to: Optional target agent for handoff
1778
+ handoff_reason: Optional reason for handoff
1779
+
1780
+ Returns:
1781
+ Dict with status and details
1782
+ """
1783
+ # Verify the human agent exists
1784
+ if not hasattr(self, "human_agents") or human_agent_id not in self.human_agents:
1785
+ return {"status": "error", "message": "Human agent not found"}
1786
+
1787
+ human_agent = self.human_agents[human_agent_id]
1788
+
1789
+ # Get the ticket
1790
+ ticket = self.tickets.find_one({"_id": ticket_id})
1791
+ if not ticket:
1792
+ return {"status": "error", "message": "Ticket not found"}
1793
+
1794
+ # Check if ticket is assigned to this agent
1795
+ if ticket.get("assigned_to") != human_agent_id:
1796
+ return {"status": "error", "message": "Ticket not assigned to this agent"}
1797
+
1798
+ # If handoff requested
1799
+ if handoff_to:
1800
+ # Determine if target is human or AI
1801
+ is_human_target = (
1802
+ hasattr(self, "human_agents") and handoff_to in self.human_agents
1803
+ )
1804
+
1805
+ is_ai_target = handoff_to in self.agents
1806
+
1807
+ if not (is_human_target or is_ai_target):
1808
+ return {"status": "error", "message": "Invalid handoff target"}
1809
+
1810
+ # Record the handoff
1811
+ self.handoffs.insert_one(
1812
+ {
1813
+ "ticket_id": ticket_id,
1814
+ "user_id": ticket["user_id"],
1815
+ "from_agent": human_agent_id,
1816
+ "to_agent": handoff_to,
1817
+ "reason": handoff_reason or "Human agent handoff",
1818
+ "query": ticket["query"],
1819
+ "timestamp": datetime.datetime.now(datetime.timezone.utc),
1820
+ }
1821
+ )
1822
+
1823
+ # Update ticket status
1824
+ self.tickets.update_one(
1825
+ {"_id": ticket_id},
1826
+ {
1827
+ "$set": {
1828
+ "assigned_to": handoff_to,
1829
+ "status": "transferred",
1830
+ "human_response": response,
1831
+ "handoff_reason": handoff_reason,
1832
+ "updated_at": datetime.datetime.now(datetime.timezone.utc),
1833
+ }
1834
+ },
1835
+ )
1836
+
1837
+ # Process based on target type
1838
+ if is_human_target:
1839
+ # Human-to-human handoff
1840
+ target_human = self.human_agents[handoff_to]
1841
+
1842
+ # Get updated context including the human's response
1843
+ context = (
1844
+ ticket.get("context", "")
1845
+ + f"\n\nHuman agent {human_agent.name}: {response}"
1846
+ )
1847
+
1848
+ # Try to hand off to the human agent
1849
+ accepted = await target_human.receive_handoff(
1850
+ ticket_id=ticket_id,
1851
+ user_id=ticket["user_id"],
1852
+ query=ticket["query"],
1853
+ context=context,
1854
+ )
1855
+
1856
+ if accepted:
1857
+ return {
1858
+ "status": "success",
1859
+ "message": f"Transferred to human agent {target_human.name}",
1860
+ "ticket_id": ticket_id,
1861
+ }
1862
+ else:
1863
+ return {
1864
+ "status": "warning",
1865
+ "message": f"Human agent {target_human.name} is unavailable",
1866
+ }
1867
+ else:
1868
+ # Human-to-AI handoff
1869
+ target_ai = self.agents[handoff_to]
1870
+
1871
+ # Return details for AI processing
1872
+ return {
1873
+ "status": "success",
1874
+ "message": f"Transferred to AI agent {handoff_to}",
1875
+ "ticket_id": ticket_id,
1876
+ "ai_agent": target_ai,
1877
+ "user_id": ticket["user_id"],
1878
+ "query": ticket["query"],
1879
+ }
1880
+
1881
+ # No handoff - just record the human response
1882
+ self.tickets.update_one(
1883
+ {"_id": ticket_id},
1884
+ {
1885
+ "$set": {
1886
+ "status": "resolved",
1887
+ "human_response": response,
1888
+ "resolved_at": datetime.datetime.now(datetime.timezone.utc),
1889
+ }
1890
+ },
1891
+ )
1892
+
1893
+ # Also record in messages for continuity
1894
+ self.database.save_message(
1895
+ ticket["user_id"],
1896
+ {
1897
+ "message": ticket["query"],
1898
+ "response": response,
1899
+ "human_agent": human_agent_id,
1900
+ "timestamp": datetime.datetime.now(datetime.timezone.utc),
1901
+ },
1902
+ )
1903
+
1904
+ return {
1905
+ "status": "success",
1906
+ "message": "Response recorded",
1907
+ "ticket_id": ticket_id,
1908
+ }
1909
+
1910
+ async def extract_and_store_insights(
1911
+ self, user_id: str, conversation: dict
1912
+ ) -> None:
1913
+ """Extract and store insights with hybrid vector/text search capabilities."""
1914
+ # Get first agent to use its OpenAI client
1915
+ if not self.agents:
1916
+ return
1917
+
1918
+ first_agent = next(iter(self.agents.values()))
1919
+
1920
+ # Create the prompt to extract insights
1921
+ prompt = f"""
1922
+ Review this conversation and extract 0-3 IMPORTANT factual insights worth remembering for future users.
1923
+ Only extract FACTUAL information that would be valuable across multiple conversations.
1924
+ Do NOT include opinions, personal preferences, or user-specific details.
1925
+
1926
+ Conversation:
1927
+ User: {conversation.get('message', '')}
1928
+ Assistant: {conversation.get('response', '')}
1929
+ """
1930
+
1931
+ # Extract insights using AI with structured parsing
1932
+ try:
1933
+ # Parse the response using the Pydantic model
1934
+ completion = first_agent._client.beta.chat.completions.parse(
1935
+ model=self.insight_model,
1936
+ messages=[
1937
+ {
1938
+ "role": "system",
1939
+ "content": "Extract important factual insights from conversations.",
1940
+ },
1941
+ {"role": "user", "content": prompt},
1942
+ ],
1943
+ response_format=CollectiveMemoryResponse,
1944
+ temperature=0.1,
1945
+ )
1946
+
1947
+ # Extract the Pydantic model
1948
+ memory_response = completion.choices[0].message.parsed
1949
+
1950
+ # Store in MongoDB (keeps all metadata and text)
1951
+ timestamp = datetime.datetime.now(datetime.timezone.utc)
1952
+ mongo_records = []
1953
+
1954
+ for insight in memory_response.insights:
1955
+ record_id = str(uuid.uuid4())
1956
+ record = {
1957
+ "_id": record_id,
1958
+ "fact": insight.fact,
1959
+ "relevance": insight.relevance,
1960
+ "timestamp": timestamp,
1961
+ "source_user_id": user_id,
1962
+ }
1963
+ mongo_records.append(record)
1964
+
1965
+ if mongo_records:
1966
+ for record in mongo_records:
1967
+ self.collective_memory.insert_one(record)
1968
+
1969
+ # Also store in Pinecone for semantic search if available
1970
+ if (
1971
+ mongo_records
1972
+ and hasattr(first_agent, "_pinecone")
1973
+ and first_agent._pinecone
1974
+ and first_agent.kb
1975
+ ):
1976
+ try:
1977
+ # Generate embeddings
1978
+ texts = [
1979
+ f"{record['fact']}: {record['relevance']}"
1980
+ for record in mongo_records
1981
+ ]
1982
+ embeddings = first_agent._pinecone.inference.embed(
1983
+ model=first_agent._pinecone_embedding_model,
1984
+ inputs=texts,
1985
+ parameters={"input_type": "passage",
1986
+ "truncate": "END"},
1987
+ )
1988
+
1989
+ # Create vectors for Pinecone
1990
+ vectors = []
1991
+ for record, embedding in zip(mongo_records, embeddings):
1992
+ vectors.append(
1993
+ {
1994
+ "id": record["_id"],
1995
+ "values": embedding.values,
1996
+ "metadata": {
1997
+ "fact": record["fact"],
1998
+ "relevance": record["relevance"],
1999
+ "timestamp": str(timestamp),
2000
+ "source_user_id": user_id,
2001
+ },
2002
+ }
2003
+ )
2004
+
2005
+ # Store in Pinecone
2006
+ first_agent.kb.upsert(
2007
+ vectors=vectors, namespace="collective_memory"
2008
+ )
2009
+ print(
2010
+ f"Stored {len(mongo_records)} insights in both MongoDB and Pinecone"
2011
+ )
2012
+ except Exception as e:
2013
+ print(f"Error storing insights in Pinecone: {e}")
2014
+ else:
2015
+ print(f"Stored {len(mongo_records)} insights in MongoDB only")
2016
+
2017
+ except Exception as e:
2018
+ print(f"Failed to extract insights: {str(e)}")
2019
+
2020
+ def search_collective_memory(self, query: str, limit: int = 5) -> str:
2021
+ """Search the collective memory using a hybrid approach.
2022
+
2023
+ First tries semantic vector search through Pinecone, then falls back to
2024
+ MongoDB text search, and finally to recency-based search as needed.
2025
+
2026
+ Args:
2027
+ query: The search query
2028
+ limit: Maximum number of results to return
2029
+
2030
+ Returns:
2031
+ Formatted string with relevant insights
2032
+ """
2033
+ try:
2034
+ if not self.enable_collective_memory:
2035
+ return "Collective memory feature is disabled."
2036
+
2037
+ results = []
2038
+ search_method = "recency" # Default method if others fail
2039
+
2040
+ # Try semantic search with Pinecone first
2041
+ if self.agents:
2042
+ first_agent = next(iter(self.agents.values()))
2043
+ if (
2044
+ hasattr(first_agent, "_pinecone")
2045
+ and first_agent._pinecone
2046
+ and first_agent.kb
2047
+ ):
2048
+ try:
2049
+ # Generate embedding for query
2050
+ embedding = first_agent._pinecone.inference.embed(
2051
+ model=first_agent._pinecone_embedding_model,
2052
+ inputs=[query],
2053
+ parameters={"input_type": "passage",
2054
+ "truncate": "END"},
2055
+ )
2056
+
2057
+ # Search Pinecone
2058
+ pinecone_results = first_agent.kb.query(
2059
+ vector=embedding[0].values,
2060
+ top_k=limit * 2, # Get more results to allow for filtering
2061
+ include_metadata=True,
2062
+ namespace="collective_memory",
2063
+ )
2064
+
2065
+ # Extract results from Pinecone
2066
+ if pinecone_results.matches:
2067
+ for match in pinecone_results.matches:
2068
+ if hasattr(match, "metadata") and match.metadata:
2069
+ results.append(
2070
+ {
2071
+ "fact": match.metadata.get(
2072
+ "fact", "Unknown fact"
2073
+ ),
2074
+ "relevance": match.metadata.get(
2075
+ "relevance", ""
2076
+ ),
2077
+ "score": match.score,
2078
+ }
2079
+ )
2080
+
2081
+ # Get top results
2082
+ results = sorted(
2083
+ results, key=lambda x: x.get("score", 0), reverse=True
2084
+ )[:limit]
2085
+ search_method = "semantic"
2086
+ except Exception as e:
2087
+ print(f"Pinecone search error: {e}")
2088
+
2089
+ # Fall back to MongoDB keyword search if needed
2090
+ if not results:
2091
+ try:
2092
+ # First try text search if we have the index
2093
+ mongo_results = list(
2094
+ self.collective_memory.find(
2095
+ {"$text": {"$search": query}},
2096
+ {"score": {"$meta": "textScore"}},
2097
+ )
2098
+ .sort([("score", {"$meta": "textScore"})])
2099
+ .limit(limit)
2100
+ )
2101
+
2102
+ if mongo_results:
2103
+ results = mongo_results
2104
+ search_method = "keyword"
2105
+ else:
2106
+ # Fall back to most recent insights
2107
+ results = list(
2108
+ self.collective_memory.find()
2109
+ .sort("timestamp", -1)
2110
+ .limit(limit)
2111
+ )
2112
+ search_method = "recency"
2113
+ except Exception as e:
2114
+ print(f"MongoDB search error: {e}")
2115
+ # Final fallback - just get most recent
2116
+ results = list(
2117
+ self.collective_memory.find().sort("timestamp", -1).limit(limit)
2118
+ )
2119
+
2120
+ # Format the results
2121
+ if not results:
2122
+ return "No collective knowledge available."
2123
+
2124
+ formatted = [
2125
+ f"## Relevant Collective Knowledge (using {search_method} search)"
2126
+ ]
2127
+ for insight in results:
2128
+ formatted.append(
2129
+ f"- **{insight.get('fact')}** _{insight.get('relevance', '')}_"
2130
+ )
2131
+
2132
+ return "\n".join(formatted)
2133
+
2134
+ except Exception as e:
2135
+ print(f"Error searching collective memory: {str(e)}")
2136
+ return "Error retrieving collective knowledge."
2137
+
1133
2138
  def register(self, name: str, agent: AI, specialization: str):
1134
2139
  """Register a specialized agent with the multi-agent system."""
2140
+ # Make agent time-aware first
2141
+ agent.make_time_aware(self.default_timezone)
2142
+
2143
+ # Apply swarm directive to the agent
2144
+ agent._instructions = f"{self.formatted_directive}\n\n{agent._instructions}"
2145
+
1135
2146
  # Add the agent to the system first
1136
2147
  self.agents[name] = agent
1137
2148
  self.specializations[name] = specialization
1138
2149
 
2150
+ # Add collective memory tool to the agent
2151
+ @agent.add_tool
2152
+ def query_collective_knowledge(query: str) -> str:
2153
+ """Query the swarm's collective knowledge from all users.
2154
+
2155
+ Args:
2156
+ query (str): The search query to look for in collective knowledge
2157
+
2158
+ Returns:
2159
+ str: Relevant insights from the swarm's collective memory
2160
+ """
2161
+ return self.search_collective_memory(query)
2162
+
1139
2163
  print(
1140
2164
  f"Registered agent: {name}, specialization: {specialization[:50]}...")
1141
2165
  print(f"Current agents: {list(self.agents.keys())}")
@@ -1236,11 +2260,11 @@ class Swarm:
1236
2260
  STRICT HANDOFF GUIDANCE:
1237
2261
  1. You must use ONLY the EXACT agent names listed below for handoffs:
1238
2262
  {handoff_examples}
1239
-
2263
+
1240
2264
  2. DO NOT INVENT, MODIFY, OR CREATE NEW AGENT NAMES like "Smart Contract Developer" or "Technical Expert"
1241
-
2265
+
1242
2266
  3. For technical implementation questions, use "developer" (not variations like "developer expert" or "tech specialist")
1243
-
2267
+
1244
2268
  4. ONLY these EXACT agent names will work for handoffs: {', '.join(available_targets)}
1245
2269
  """
1246
2270
 
@@ -1251,101 +2275,545 @@ class Swarm:
1251
2275
  f"Updated handoff capabilities for {agent_name} with targets: {available_targets}"
1252
2276
  )
1253
2277
 
1254
- async def process(self, user_id: str, user_text: str) -> AsyncGenerator[str, None]:
1255
- """Process the user request with appropriate agent and handle handoffs."""
2278
+ async def process(
2279
+ self, user_id: str, user_text: str, timezone: str = None
2280
+ ) -> AsyncGenerator[str, None]:
2281
+ """Process the user request with appropriate agent and handle handoffs.
2282
+
2283
+ Args:
2284
+ user_id (str): Unique user identifier
2285
+ user_text (str): User's text input
2286
+ timezone (str, optional): User-specific timezone
2287
+ """
1256
2288
  try:
1257
- # Check if any agents are registered
2289
+ # Handle special ticket management commands
2290
+ if user_text.lower().startswith("!ticket"):
2291
+ yield await self._process_ticket_commands(user_id, user_text)
2292
+ return
2293
+
2294
+ # Handle special commands
2295
+ if user_text.strip().lower().startswith("!memory "):
2296
+ query = user_text[8:].strip()
2297
+ yield self.search_collective_memory(query)
2298
+ return
2299
+
2300
+ # Check for registered agents
1258
2301
  if not self.agents:
1259
2302
  yield "Error: No agents are registered with the system. Please register at least one agent first."
1260
2303
  return
1261
2304
 
1262
- # Get routing decision
2305
+ # Get initial routing and agent
1263
2306
  first_agent = next(iter(self.agents.values()))
1264
2307
  agent_name = await self._get_routing_decision(first_agent, user_text)
1265
2308
  current_agent = self.agents[agent_name]
1266
2309
  print(f"Starting conversation with agent: {agent_name}")
1267
2310
 
1268
- # Initialize a flag for handoff detection
1269
- handoff_detected = False
1270
- response_started = False
1271
-
1272
- # Reset handoff info for this interaction
2311
+ # Reset handoff info
1273
2312
  current_agent._handoff_info = None
1274
2313
 
1275
- # Process initial agent's response
1276
- async for chunk in current_agent.text(user_id, user_text):
1277
- # Check for handoff after each chunk
1278
- if current_agent._handoff_info and not handoff_detected:
1279
- handoff_detected = True
1280
- target_name = current_agent._handoff_info["target"]
1281
- target_agent = self.agents[target_name]
1282
- reason = current_agent._handoff_info["reason"]
2314
+ # Response tracking
2315
+ final_response = ""
2316
+
2317
+ # Process response stream
2318
+ async for chunk in self._stream_response(
2319
+ user_id, user_text, current_agent, timezone
2320
+ ):
2321
+ yield chunk
2322
+ final_response += chunk
2323
+
2324
+ # Post-processing: learn from conversation
2325
+ conversation = {
2326
+ "user_id": user_id,
2327
+ "message": user_text,
2328
+ "response": final_response,
2329
+ }
2330
+
2331
+ # Run post-processing tasks concurrently
2332
+ tasks = []
2333
+
2334
+ # Add collective memory task if enabled
2335
+ if self.enable_collective_memory:
2336
+ tasks.append(self.extract_and_store_insights(
2337
+ user_id, conversation))
2338
+
2339
+ # Run all post-processing tasks concurrently
2340
+ if tasks:
2341
+ # Don't block - run asynchronously
2342
+ asyncio.create_task(self._run_post_processing_tasks(tasks))
2343
+
2344
+ except Exception as e:
2345
+ print(f"Error in multi-agent processing: {str(e)}")
2346
+ print(traceback.format_exc())
2347
+ yield "\n\nI apologize for the technical difficulty.\n\n"
2348
+
2349
+ async def _process_ticket_commands(self, user_id: str, command: str) -> str:
2350
+ """Process ticket management commands directly in chat."""
2351
+ parts = command.strip().split(" ", 2)
2352
+
2353
+ # Check if user is a registered human agent
2354
+ is_human_agent = False
2355
+ human_agent = None
2356
+ if hasattr(self, "human_agents"):
2357
+ for agent_id, agent in self.human_agents.items():
2358
+ if agent_id == user_id:
2359
+ is_human_agent = True
2360
+ human_agent = agent
2361
+ break
2362
+
2363
+ if not is_human_agent:
2364
+ return "⚠️ Only registered human agents can use ticket commands."
2365
+
2366
+ # Process various ticket commands
2367
+ if len(parts) > 1:
2368
+ action = parts[1].lower()
2369
+
2370
+ # List tickets assigned to this human agent
2371
+ if action == "list":
2372
+ tickets = list(
2373
+ self.tickets.find(
2374
+ {"assigned_to": user_id, "status": "pending"})
2375
+ )
2376
+
2377
+ if not tickets:
2378
+ return "📋 You have no pending tickets."
2379
+
2380
+ ticket_list = ["## Your Pending Tickets", ""]
2381
+ for i, ticket in enumerate(tickets, 1):
2382
+ created = ticket.get(
2383
+ "created_at", datetime.datetime.now(
2384
+ datetime.timezone.utc)
2385
+ )
2386
+ time_ago = self._format_time_ago(created)
2387
+
2388
+ ticket_list.append(
2389
+ f"**{i}. Ticket {ticket['_id'][:8]}...** ({time_ago})"
2390
+ )
2391
+ ticket_list.append(
2392
+ f"Query: {ticket.get('query', 'No query')[:100]}..."
2393
+ )
2394
+ ticket_list.append("")
2395
+
2396
+ return "\n".join(ticket_list)
2397
+
2398
+ # View a specific ticket
2399
+ elif action == "view" and len(parts) > 2:
2400
+ ticket_id = parts[2]
2401
+ ticket = self.tickets.find_one(
2402
+ {"_id": {"$regex": f"^{ticket_id}.*"}, "assigned_to": user_id}
2403
+ )
2404
+
2405
+ if not ticket:
2406
+ return f"⚠️ No ticket found with ID starting with '{ticket_id}'"
2407
+
2408
+ context = ticket.get("context", "No previous context")
2409
+ query = ticket.get("query", "No query")
2410
+ created = ticket.get(
2411
+ "created_at", datetime.datetime.now(datetime.timezone.utc)
2412
+ )
2413
+ time_ago = self._format_time_ago(created)
2414
+
2415
+ return f"""## Ticket Details ({ticket['_id']})
2416
+
2417
+ Status: {ticket.get('status', 'pending')}
2418
+ Created: {time_ago}
2419
+
2420
+ ### User Query
2421
+ {query}
2422
+
2423
+ ### Conversation Context
2424
+ {context}
2425
+ """
2426
+
2427
+ # Respond to a ticket
2428
+ elif action == "respond" and len(parts) > 2:
2429
+ # Format: !ticket respond ticket_id response text here
2430
+ response_parts = parts[2].split(" ", 1)
2431
+ if len(response_parts) < 2:
2432
+ return "⚠️ Format: !ticket respond ticket_id your response text"
2433
+
2434
+ ticket_id = response_parts[0]
2435
+ response_text = response_parts[1]
2436
+
2437
+ # Find the ticket
2438
+ ticket = self.tickets.find_one(
2439
+ {"_id": {"$regex": f"^{ticket_id}.*"}, "assigned_to": user_id}
2440
+ )
2441
+ if not ticket:
2442
+ return f"⚠️ No ticket found with ID starting with '{ticket_id}'"
1283
2443
 
1284
- # Record handoff without waiting
1285
- asyncio.create_task(
1286
- self._record_handoff(
1287
- user_id, agent_name, target_name, reason, user_text
2444
+ # Process the response
2445
+ response_result = await human_agent.respond(
2446
+ ticket["_id"], response_text
2447
+ )
2448
+
2449
+ # Check if response was successful
2450
+ if response_result.get("status") != "success":
2451
+ return f"⚠️ Failed to respond to ticket: {response_result.get('message', 'Unknown error')}"
2452
+
2453
+ # Update ticket and save response
2454
+ self.tickets.update_one(
2455
+ {"_id": ticket["_id"]},
2456
+ {
2457
+ "$set": {
2458
+ "status": "resolved",
2459
+ "human_response": response_text,
2460
+ "resolved_at": datetime.datetime.now(datetime.timezone.utc),
2461
+ }
2462
+ },
2463
+ )
2464
+
2465
+ # Also record in messages for continuity
2466
+ self.database.save_message(
2467
+ ticket["user_id"],
2468
+ {
2469
+ "message": ticket["query"],
2470
+ "response": response_text,
2471
+ "human_agent": user_id,
2472
+ "timestamp": datetime.datetime.now(datetime.timezone.utc),
2473
+ },
2474
+ )
2475
+
2476
+ return f"✅ Response recorded for ticket {ticket_id}. The ticket has been marked as resolved."
2477
+
2478
+ # Transfer a ticket
2479
+ elif action == "transfer" and len(parts) > 2:
2480
+ # Format: !ticket transfer ticket_id target_agent [reason]
2481
+ transfer_parts = parts[2].split(" ", 2)
2482
+ if len(transfer_parts) < 2:
2483
+ return "⚠️ Format: !ticket transfer ticket_id target_agent [reason]"
2484
+
2485
+ ticket_id = transfer_parts[0]
2486
+ target_agent = transfer_parts[1]
2487
+ reason = (
2488
+ transfer_parts[2]
2489
+ if len(transfer_parts) > 2
2490
+ else "Human agent transfer"
2491
+ )
2492
+
2493
+ # Find the ticket
2494
+ ticket = self.tickets.find_one(
2495
+ {"_id": {"$regex": f"^{ticket_id}.*"}, "assigned_to": user_id}
2496
+ )
2497
+ if not ticket:
2498
+ return f"⚠️ No ticket found with ID starting with '{ticket_id}'"
2499
+
2500
+ # Handle transfer logic
2501
+ # Determine if target is human or AI
2502
+ is_human_target = (
2503
+ hasattr(
2504
+ self, "human_agents") and target_agent in self.human_agents
2505
+ )
2506
+
2507
+ is_ai_target = target_agent in self.agents
2508
+
2509
+ if not (is_human_target or is_ai_target):
2510
+ return f"⚠️ Invalid transfer target '{target_agent}'. Must be a valid agent name."
2511
+
2512
+ # Record the handoff
2513
+ self.handoffs.insert_one(
2514
+ {
2515
+ "ticket_id": ticket["_id"],
2516
+ "user_id": ticket["user_id"],
2517
+ "from_agent": user_id,
2518
+ "to_agent": target_agent,
2519
+ "reason": reason,
2520
+ "query": ticket["query"],
2521
+ "timestamp": datetime.datetime.now(datetime.timezone.utc),
2522
+ }
2523
+ )
2524
+
2525
+ # Update ticket status in database
2526
+ self.tickets.update_one(
2527
+ {"_id": ticket["_id"]},
2528
+ {
2529
+ "$set": {
2530
+ "assigned_to": target_agent,
2531
+ "status": "transferred",
2532
+ "handoff_reason": reason,
2533
+ "updated_at": datetime.datetime.now(datetime.timezone.utc),
2534
+ }
2535
+ },
2536
+ )
2537
+
2538
+ # Process based on target type
2539
+ if is_human_target:
2540
+ # Human-to-human handoff
2541
+ target_human = self.human_agents[target_agent]
2542
+
2543
+ # Get updated context including the current human's notes
2544
+ context = (
2545
+ ticket.get("context", "")
2546
+ + f"\n\nHuman agent {human_agent.name}: Transferring with note: {reason}"
2547
+ )
2548
+
2549
+ # Try to hand off to the human agent
2550
+ accepted = await target_human.receive_handoff(
2551
+ ticket_id=ticket["_id"],
2552
+ user_id=ticket["user_id"],
2553
+ query=ticket["query"],
2554
+ context=context,
2555
+ )
2556
+
2557
+ if accepted:
2558
+ return (
2559
+ f"✅ Ticket transferred to human agent {target_human.name}"
1288
2560
  )
2561
+ else:
2562
+ return f"⚠️ Human agent {target_human.name} is unavailable. Ticket is still transferred but pending their acceptance."
2563
+ else:
2564
+ # Human-to-AI handoff
2565
+ return f"✅ Ticket transferred to AI agent {target_agent}. The AI will handle this in the user's next interaction."
2566
+
2567
+ # Help command or invalid format
2568
+ help_text = """
2569
+ ## Ticket Commands
2570
+
2571
+ - `!ticket list` - Show your pending tickets
2572
+ - `!ticket view [ticket_id]` - View details of a specific ticket
2573
+ - `!ticket respond [ticket_id] [response]` - Respond to a ticket
2574
+ - `!ticket transfer [ticket_id] [target_agent] [reason]` - Transfer ticket to another agent
2575
+ """
2576
+ return help_text.strip()
2577
+
2578
+ def _format_time_ago(self, timestamp):
2579
+ """Format a timestamp as a human-readable time ago string."""
2580
+ now = datetime.datetime.now(datetime.timezone.utc)
2581
+ diff = now - timestamp
2582
+
2583
+ if diff.days > 0:
2584
+ return f"{diff.days} days ago"
2585
+
2586
+ hours, remainder = divmod(diff.seconds, 3600)
2587
+ minutes, seconds = divmod(remainder, 60)
2588
+
2589
+ if hours > 0:
2590
+ return f"{hours} hours ago"
2591
+ if minutes > 0:
2592
+ return f"{minutes} minutes ago"
2593
+ return "just now"
2594
+
2595
+ async def _stream_response(
2596
+ self, user_id, user_text, current_agent, timezone=None
2597
+ ) -> AsyncGenerator[str, None]:
2598
+ """Stream response from an agent, handling potential handoffs to AI or human agents."""
2599
+ handoff_detected = False
2600
+ response_started = False
2601
+ full_response = ""
2602
+ agent_name = None # For the agent's name
2603
+
2604
+ # Get agent name for recording purposes
2605
+ for name, agent in self.agents.items():
2606
+ if agent == current_agent:
2607
+ agent_name = name
2608
+ break
2609
+
2610
+ # Get recent feedback for this agent to improve the response
2611
+ recent_feedback = []
2612
+
2613
+ if self.enable_critic and hasattr(self, "critic"):
2614
+ # Get recent feedback for this specific agent
2615
+ recent_feedback = self.critic.get_agent_feedback(
2616
+ agent_name, limit=3)
2617
+ print(
2618
+ f"Retrieved {len(recent_feedback)} feedback items for agent {agent_name}"
2619
+ )
2620
+
2621
+ # Augment user text with feedback instructions if available
2622
+ augmented_instruction = user_text
2623
+ if recent_feedback:
2624
+ # Create targeted improvement instructions based on past feedback
2625
+ feedback_summary = ""
2626
+ for feedback in recent_feedback:
2627
+ area = feedback.get("improvement_area", "Unknown")
2628
+ recommendation = feedback.get(
2629
+ "recommendation", "No specific recommendation"
2630
+ )
2631
+ feedback_summary += f"- {area}: {recommendation}\n"
2632
+
2633
+ # Add as hidden instructions to the agent
2634
+ augmented_instruction = f"""
2635
+ {user_text}
2636
+
2637
+ [SYSTEM NOTE: Apply these improvements from recent feedback:
2638
+ {feedback_summary}
2639
+ The user will not see these instructions.]
2640
+ """
2641
+ print("Added feedback-based improvement instructions to prompt")
2642
+
2643
+ async for chunk in current_agent.text(
2644
+ user_id, augmented_instruction, timezone, user_text
2645
+ ):
2646
+ # Accumulate the full response for critic analysis
2647
+ full_response += chunk
2648
+
2649
+ # Check for handoff after each chunk
2650
+ if current_agent._handoff_info and not handoff_detected:
2651
+ handoff_detected = True
2652
+ target_name = current_agent._handoff_info["target"]
2653
+ reason = current_agent._handoff_info["reason"]
2654
+ is_human_target = current_agent._handoff_info.get(
2655
+ "is_human_target", False
2656
+ )
2657
+
2658
+ # Record the handoff without waiting
2659
+ asyncio.create_task(
2660
+ self._record_handoff(
2661
+ user_id,
2662
+ agent_name or "unknown_agent",
2663
+ target_name,
2664
+ reason,
2665
+ user_text,
2666
+ )
2667
+ )
2668
+
2669
+ # Add separator if needed
2670
+ if response_started:
2671
+ yield "\n\n---\n\n"
2672
+
2673
+ # Handle differently based on target type (AI vs human)
2674
+ if is_human_target and hasattr(self, "human_agents"):
2675
+ # Create a ticket in the database
2676
+ ticket_id = str(uuid.uuid4())
2677
+
2678
+ # Get conversation history
2679
+ context = ""
2680
+ if hasattr(current_agent, "get_memory_context"):
2681
+ context = current_agent.get_memory_context(user_id)
2682
+
2683
+ # Store ticket in database
2684
+ self.tickets.insert_one(
2685
+ {
2686
+ "_id": ticket_id,
2687
+ "user_id": user_id,
2688
+ "query": user_text,
2689
+ "context": context,
2690
+ "ai_response_before_handoff": full_response,
2691
+ "assigned_to": target_name,
2692
+ "status": "pending",
2693
+ "created_at": datetime.datetime.now(datetime.timezone.utc),
2694
+ }
1289
2695
  )
1290
2696
 
1291
- # Process with target agent
1292
- print(f"[HANDOFF] Forwarding to {target_name}")
2697
+ # Get the human agent
2698
+ human_agent = self.human_agents.get(target_name)
2699
+
2700
+ if human_agent:
2701
+ # Try to hand off to the human agent
2702
+ accepted = await human_agent.receive_handoff(
2703
+ ticket_id=ticket_id,
2704
+ user_id=user_id,
2705
+ query=user_text,
2706
+ context=context,
2707
+ )
2708
+
2709
+ if accepted:
2710
+ human_availability = {
2711
+ "available": "available now",
2712
+ "busy": "busy but will respond soon",
2713
+ "offline": "currently offline but will respond when back",
2714
+ }.get(
2715
+ human_agent.availability_status,
2716
+ "will respond when available",
2717
+ )
2718
+
2719
+ # Provide a friendly handoff message to the user
2720
+ handoff_message = f"""
2721
+ I've transferred your question to {human_agent.name}, who specializes in {human_agent.specialization}.
2722
+
2723
+ A human specialist will provide a more tailored response. They are {human_availability}.
2724
+
2725
+ Your ticket ID is: {ticket_id}
2726
+ """
2727
+ yield handoff_message.strip()
2728
+ else:
2729
+ # Human agent couldn't accept - fall back to an AI agent
2730
+ yield "I tried to transfer your question to a human specialist, but they're unavailable at the moment. Let me help you instead.\n\n"
2731
+
2732
+ # Get the first AI agent
2733
+ fallback_agent = next(iter(self.agents.values()))
2734
+
2735
+ # Stream from fallback AI agent
2736
+ async for new_chunk in fallback_agent.text(
2737
+ user_id, user_text
2738
+ ):
2739
+ yield new_chunk
2740
+ # Force immediate delivery
2741
+ await asyncio.sleep(0)
2742
+ else:
2743
+ yield "I tried to transfer your question to a human specialist, but there was an error. Let me help you instead.\n\n"
2744
+
2745
+ # Fallback to first AI agent
2746
+ fallback_agent = next(iter(self.agents.values()))
2747
+ async for new_chunk in fallback_agent.text(user_id, user_text):
2748
+ yield new_chunk
2749
+ await asyncio.sleep(0)
2750
+ else:
2751
+ # Standard AI-to-AI handoff
2752
+ target_agent = self.agents[target_name]
2753
+
2754
+ # Pass to target agent with comprehensive instructions
1293
2755
  handoff_query = f"""
1294
2756
  Answer this ENTIRE question completely from scratch:
1295
-
1296
2757
  {user_text}
1297
-
2758
+
1298
2759
  IMPORTANT INSTRUCTIONS:
1299
2760
  1. Address ALL aspects of the question comprehensively
1300
- 2. Organize your response in a logical, structured manner
1301
- 3. Include both explanations AND implementations as needed
1302
- 4. Do not mention any handoff or that you're continuing from another agent
1303
- 5. Answer as if you are addressing the complete question from the beginning
1304
- 6. Consider any relevant context from previous conversation
2761
+ 2. Include both explanations AND implementations as needed
2762
+ 3. Do not mention any handoff or that you're continuing from another agent
2763
+ 4. Consider any relevant context from previous conversation
1305
2764
  """
1306
2765
 
1307
- # If we've already started returning some text, add a separator
1308
- if response_started:
1309
- yield "\n\n---\n\n"
1310
-
1311
- # Stream directly from target agent
2766
+ # Stream from target agent
1312
2767
  async for new_chunk in target_agent.text(user_id, handoff_query):
1313
2768
  yield new_chunk
1314
- # Force immediate delivery of each chunk
1315
- await asyncio.sleep(0)
1316
- return
1317
- else:
1318
- # Only yield content if no handoff has been detected
1319
- if not handoff_detected:
1320
- response_started = True
1321
- yield chunk
1322
2769
  await asyncio.sleep(0) # Force immediate delivery
2770
+ return
2771
+
2772
+ # Regular response if no handoff detected
2773
+ if not handoff_detected:
2774
+ response_started = True
2775
+ yield chunk
2776
+ await asyncio.sleep(0) # Force immediate delivery
2777
+
2778
+ # After full response is delivered, invoke critic (if enabled)
2779
+ if self.enable_critic and hasattr(self, "critic") and agent_name:
2780
+ # Schedule async analysis without blocking
2781
+ asyncio.create_task(
2782
+ self.critic.analyze_interaction(
2783
+ agent_name=agent_name,
2784
+ user_query=user_text,
2785
+ response=full_response,
2786
+ )
2787
+ )
2788
+ print(f"Scheduled critic analysis for {agent_name} response")
1323
2789
 
2790
+ async def _run_post_processing_tasks(self, tasks):
2791
+ """Run multiple post-processing tasks concurrently."""
2792
+ try:
2793
+ await asyncio.gather(*tasks)
1324
2794
  except Exception as e:
1325
- print(f"Error in multi-agent processing: {str(e)}")
1326
- print(traceback.format_exc())
1327
- yield "\n\nI apologize for the technical difficulty.\n\n"
2795
+ print(f"Error in post-processing tasks: {e}")
1328
2796
 
1329
2797
  async def _get_routing_decision(self, agent, user_text):
1330
2798
  """Get routing decision in parallel to reduce latency."""
1331
2799
  enhanced_prompt = f"""
1332
2800
  Analyze this user query carefully to determine the MOST APPROPRIATE specialist.
1333
-
2801
+
1334
2802
  User query: "{user_text}"
1335
-
2803
+
1336
2804
  Available specialists:
1337
2805
  {json.dumps(self.specializations, indent=2)}
1338
-
2806
+
1339
2807
  CRITICAL ROUTING INSTRUCTIONS:
1340
2808
  1. For compound questions with multiple aspects spanning different domains,
1341
2809
  choose the specialist who should address the CONCEPTUAL or EDUCATIONAL aspects first.
1342
-
2810
+
1343
2811
  2. Choose implementation specialists (technical, development, coding) only when
1344
2812
  the query is PURELY about implementation with no conceptual explanation needed.
1345
-
2813
+
1346
2814
  3. When a query involves a SEQUENCE (like "explain X and then do Y"),
1347
2815
  prioritize the specialist handling the FIRST part of the sequence.
1348
-
2816
+
1349
2817
  Return ONLY the name of the single most appropriate specialist.
1350
2818
  """
1351
2819
 
@@ -1396,3 +2864,134 @@ class Swarm:
1396
2864
 
1397
2865
  # Fallback to first agent
1398
2866
  return list(self.agents.keys())[0]
2867
+
2868
+
2869
+ class Critic:
2870
+ """System that evaluates agent responses and suggests improvements."""
2871
+
2872
+ def __init__(self, swarm, critique_model="gpt-4o-mini"):
2873
+ """Initialize the critic system.
2874
+
2875
+ Args:
2876
+ swarm: The agent swarm to monitor
2877
+ critique_model: Model to use for evaluations
2878
+ """
2879
+ self.swarm = swarm
2880
+ self.critique_model = critique_model
2881
+ self.feedback_collection = swarm.database.db["agent_feedback"]
2882
+
2883
+ # Create index for feedback collection
2884
+ if "agent_feedback" not in swarm.database.db.list_collection_names():
2885
+ swarm.database.db.create_collection("agent_feedback")
2886
+ self.feedback_collection.create_index([("agent_name", 1)])
2887
+ self.feedback_collection.create_index([("improvement_area", 1)])
2888
+
2889
+ async def analyze_interaction(
2890
+ self,
2891
+ agent_name,
2892
+ user_query,
2893
+ response,
2894
+ ):
2895
+ """Analyze an agent interaction and provide improvement feedback."""
2896
+ # Get first agent's client for analysis
2897
+ first_agent = next(iter(self.swarm.agents.values()))
2898
+
2899
+ prompt = f"""
2900
+ Analyze this agent interaction to identify specific improvements.
2901
+
2902
+ INTERACTION:
2903
+ User query: {user_query}
2904
+ Agent response: {response}
2905
+
2906
+ Provide feedback on accuracy, completeness, clarity, efficiency, and tone.
2907
+ """
2908
+
2909
+ try:
2910
+ # Parse the response
2911
+ completion = first_agent._client.beta.chat.completions.parse(
2912
+ model=self.critique_model,
2913
+ messages=[
2914
+ {
2915
+ "role": "system",
2916
+ "content": "You are a helpful critic evaluating AI responses.",
2917
+ },
2918
+ {"role": "user", "content": prompt},
2919
+ ],
2920
+ response_format=CritiqueFeedback,
2921
+ temperature=0.2,
2922
+ )
2923
+
2924
+ # Extract the Pydantic model
2925
+ feedback = completion.choices[0].message.parsed
2926
+
2927
+ # Manual validation - ensure score is between 0 and 1
2928
+ if feedback.overall_score < 0:
2929
+ feedback.overall_score = 0.0
2930
+ elif feedback.overall_score > 1:
2931
+ feedback.overall_score = 1.0
2932
+
2933
+ # Store feedback in database
2934
+ for area in feedback.improvement_areas:
2935
+ self.feedback_collection.insert_one(
2936
+ {
2937
+ "agent_name": agent_name,
2938
+ "user_query": user_query,
2939
+ "timestamp": datetime.datetime.now(datetime.timezone.utc),
2940
+ "improvement_area": area.area,
2941
+ "issue": area.issue,
2942
+ "recommendation": area.recommendation,
2943
+ "overall_score": feedback.overall_score,
2944
+ "priority": feedback.priority,
2945
+ }
2946
+ )
2947
+
2948
+ # If high priority feedback, schedule immediate learning task
2949
+ if feedback.priority == "high" and feedback.improvement_areas:
2950
+ top_issue = feedback.improvement_areas[0]
2951
+ await self.schedule_improvement_task(agent_name, top_issue)
2952
+
2953
+ return feedback
2954
+
2955
+ except Exception as e:
2956
+ print(f"Error in critic analysis: {str(e)}")
2957
+ return None
2958
+
2959
+ async def schedule_improvement_task(self, agent_name, issue):
2960
+ """Execute improvement task immediately."""
2961
+ if agent_name in self.swarm.agents:
2962
+ agent = self.swarm.agents[agent_name]
2963
+
2964
+ # Create topic for improvement
2965
+ topic = (
2966
+ f"How to improve {issue['area'].lower()} in responses: {issue['issue']}"
2967
+ )
2968
+
2969
+ # Execute research directly
2970
+ result = await agent.research_and_learn(topic)
2971
+
2972
+ print(
2973
+ f"📝 Executed improvement task for {agent_name}: {issue['area']}")
2974
+ return result
2975
+
2976
+ def get_agent_feedback(self, agent_name=None, limit=10):
2977
+ """Get recent feedback for an agent or all agents."""
2978
+ query = {"agent_name": agent_name} if agent_name else {}
2979
+ feedback = list(
2980
+ self.feedback_collection.find(query).sort(
2981
+ "timestamp", -1).limit(limit)
2982
+ )
2983
+ return feedback
2984
+
2985
+ def get_improvement_trends(self):
2986
+ """Get trends in improvement areas across all agents."""
2987
+ pipeline = [
2988
+ {
2989
+ "$group": {
2990
+ "_id": "$improvement_area",
2991
+ "count": {"$sum": 1},
2992
+ "avg_score": {"$avg": "$overall_score"},
2993
+ }
2994
+ },
2995
+ {"$sort": {"count": -1}},
2996
+ ]
2997
+ return list(self.feedback_collection.aggregate(pipeline))