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 +1681 -82
- solana_agent-8.0.0.dist-info/METADATA +248 -0
- solana_agent-8.0.0.dist-info/RECORD +6 -0
- solana_agent-6.0.1.dist-info/METADATA +0 -139
- solana_agent-6.0.1.dist-info/RECORD +0 -6
- {solana_agent-6.0.1.dist-info → solana_agent-8.0.0.dist-info}/LICENSE +0 -0
- {solana_agent-6.0.1.dist-info → solana_agent-8.0.0.dist-info}/WHEEL +0 -0
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
|
|
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
|
-
|
|
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(
|
|
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":
|
|
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__(
|
|
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
|
-
|
|
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(
|
|
1255
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
-
#
|
|
1292
|
-
|
|
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.
|
|
1301
|
-
3.
|
|
1302
|
-
4.
|
|
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
|
-
#
|
|
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
|
|
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))
|