swarms 7.8.4__py3-none-any.whl → 7.8.7__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.
- swarms/agents/ape_agent.py +5 -22
- swarms/agents/consistency_agent.py +1 -1
- swarms/agents/i_agent.py +1 -1
- swarms/agents/reasoning_agents.py +99 -3
- swarms/agents/reasoning_duo.py +1 -1
- swarms/cli/main.py +1 -1
- swarms/communication/__init__.py +1 -0
- swarms/communication/duckdb_wrap.py +32 -2
- swarms/communication/pulsar_struct.py +45 -19
- swarms/communication/redis_wrap.py +56 -11
- swarms/communication/supabase_wrap.py +1659 -0
- swarms/prompts/prompt.py +0 -3
- swarms/schemas/agent_completion_response.py +71 -0
- swarms/schemas/agent_rag_schema.py +7 -0
- swarms/schemas/conversation_schema.py +9 -0
- swarms/schemas/llm_agent_schema.py +99 -81
- swarms/schemas/swarms_api_schemas.py +164 -0
- swarms/structs/__init__.py +14 -11
- swarms/structs/agent.py +219 -199
- swarms/structs/agent_rag_handler.py +685 -0
- swarms/structs/base_swarm.py +2 -1
- swarms/structs/conversation.py +608 -87
- swarms/structs/csv_to_agent.py +153 -100
- swarms/structs/deep_research_swarm.py +197 -193
- swarms/structs/dynamic_conversational_swarm.py +18 -7
- swarms/structs/hiearchical_swarm.py +1 -1
- swarms/structs/hybrid_hiearchical_peer_swarm.py +2 -18
- swarms/structs/image_batch_processor.py +261 -0
- swarms/structs/interactive_groupchat.py +356 -0
- swarms/structs/ma_blocks.py +75 -0
- swarms/structs/majority_voting.py +1 -1
- swarms/structs/mixture_of_agents.py +1 -1
- swarms/structs/multi_agent_router.py +3 -2
- swarms/structs/rearrange.py +3 -3
- swarms/structs/sequential_workflow.py +3 -3
- swarms/structs/swarm_matcher.py +500 -411
- swarms/structs/swarm_router.py +15 -97
- swarms/structs/swarming_architectures.py +1 -1
- swarms/tools/mcp_client_call.py +3 -0
- swarms/utils/__init__.py +10 -2
- swarms/utils/check_all_model_max_tokens.py +43 -0
- swarms/utils/generate_keys.py +0 -27
- swarms/utils/history_output_formatter.py +5 -20
- swarms/utils/litellm_wrapper.py +208 -60
- swarms/utils/output_types.py +24 -0
- swarms/utils/vllm_wrapper.py +5 -6
- swarms/utils/xml_utils.py +37 -2
- {swarms-7.8.4.dist-info → swarms-7.8.7.dist-info}/METADATA +31 -55
- {swarms-7.8.4.dist-info → swarms-7.8.7.dist-info}/RECORD +53 -48
- swarms/structs/multi_agent_collab.py +0 -242
- swarms/structs/output_types.py +0 -6
- swarms/utils/markdown_message.py +0 -21
- swarms/utils/visualizer.py +0 -510
- swarms/utils/wrapper_clusterop.py +0 -127
- /swarms/{tools → schemas}/tool_schema_base_model.py +0 -0
- {swarms-7.8.4.dist-info → swarms-7.8.7.dist-info}/LICENSE +0 -0
- {swarms-7.8.4.dist-info → swarms-7.8.7.dist-info}/WHEEL +0 -0
- {swarms-7.8.4.dist-info → swarms-7.8.7.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,1659 @@
|
|
1
|
+
import datetime
|
2
|
+
import json
|
3
|
+
import logging
|
4
|
+
import threading
|
5
|
+
import uuid
|
6
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
7
|
+
|
8
|
+
import yaml
|
9
|
+
|
10
|
+
from swarms.communication.base_communication import (
|
11
|
+
BaseCommunication,
|
12
|
+
Message,
|
13
|
+
MessageType,
|
14
|
+
)
|
15
|
+
|
16
|
+
# Try to import loguru logger, fallback to standard logging
|
17
|
+
try:
|
18
|
+
from loguru import logger
|
19
|
+
|
20
|
+
LOGURU_AVAILABLE = True
|
21
|
+
except ImportError:
|
22
|
+
LOGURU_AVAILABLE = False
|
23
|
+
logger = None
|
24
|
+
|
25
|
+
|
26
|
+
# Custom Exceptions for Supabase Communication
|
27
|
+
class SupabaseConnectionError(Exception):
|
28
|
+
"""Custom exception for Supabase connection errors."""
|
29
|
+
|
30
|
+
pass
|
31
|
+
|
32
|
+
|
33
|
+
class SupabaseOperationError(Exception):
|
34
|
+
"""Custom exception for Supabase operation errors."""
|
35
|
+
|
36
|
+
pass
|
37
|
+
|
38
|
+
|
39
|
+
class DateTimeEncoder(json.JSONEncoder):
|
40
|
+
"""Custom JSON encoder for handling datetime objects."""
|
41
|
+
|
42
|
+
def default(self, obj):
|
43
|
+
if isinstance(obj, datetime.datetime):
|
44
|
+
return obj.isoformat()
|
45
|
+
return super().default(obj)
|
46
|
+
|
47
|
+
|
48
|
+
class SupabaseConversation(BaseCommunication):
|
49
|
+
"""
|
50
|
+
A Supabase-backed implementation of the BaseCommunication class for managing
|
51
|
+
conversation history using a Supabase (PostgreSQL) database.
|
52
|
+
|
53
|
+
Prerequisites:
|
54
|
+
- supabase-py library: pip install supabase
|
55
|
+
- Valid Supabase project URL and API key
|
56
|
+
- Network access to your Supabase instance
|
57
|
+
|
58
|
+
Attributes:
|
59
|
+
supabase_url (str): URL of the Supabase project.
|
60
|
+
supabase_key (str): Anon or service key for the Supabase project.
|
61
|
+
client (supabase.Client): The Supabase client instance.
|
62
|
+
table_name (str): Name of the table in Supabase to store conversations.
|
63
|
+
current_conversation_id (Optional[str]): ID of the currently active conversation.
|
64
|
+
tokenizer (Any): Tokenizer for counting tokens in messages.
|
65
|
+
context_length (int): Maximum number of tokens for context window.
|
66
|
+
time_enabled (bool): Flag to prepend timestamps to messages.
|
67
|
+
enable_logging (bool): Flag to enable logging.
|
68
|
+
logger (logging.Logger | loguru.Logger): Logger instance.
|
69
|
+
"""
|
70
|
+
|
71
|
+
def __init__(
|
72
|
+
self,
|
73
|
+
supabase_url: str,
|
74
|
+
supabase_key: str,
|
75
|
+
system_prompt: Optional[str] = None,
|
76
|
+
time_enabled: bool = False,
|
77
|
+
autosave: bool = False, # Standardized parameter name - less relevant for DB-backed, but kept for interface
|
78
|
+
save_filepath: str = None, # Used for export/import
|
79
|
+
tokenizer: Any = None,
|
80
|
+
context_length: int = 8192,
|
81
|
+
rules: str = None,
|
82
|
+
custom_rules_prompt: str = None,
|
83
|
+
user: str = "User:",
|
84
|
+
save_as_yaml: bool = True, # Default export format
|
85
|
+
save_as_json_bool: bool = False, # Alternative export format
|
86
|
+
token_count: bool = True,
|
87
|
+
cache_enabled: bool = True, # Currently for token counting
|
88
|
+
table_name: str = "conversations",
|
89
|
+
enable_timestamps: bool = True, # DB schema handles this with DEFAULT NOW()
|
90
|
+
enable_logging: bool = True,
|
91
|
+
use_loguru: bool = True,
|
92
|
+
max_retries: int = 3, # For Supabase API calls (not implemented yet, supabase-py might handle)
|
93
|
+
*args,
|
94
|
+
**kwargs,
|
95
|
+
):
|
96
|
+
# Lazy load Supabase with auto-installation
|
97
|
+
try:
|
98
|
+
from supabase import Client, create_client
|
99
|
+
|
100
|
+
self.supabase_client = Client
|
101
|
+
self.create_client = create_client
|
102
|
+
self.supabase_available = True
|
103
|
+
except ImportError:
|
104
|
+
# Auto-install supabase if not available
|
105
|
+
print(
|
106
|
+
"📦 Supabase not found. Installing automatically..."
|
107
|
+
)
|
108
|
+
try:
|
109
|
+
import subprocess
|
110
|
+
import sys
|
111
|
+
|
112
|
+
# Install supabase
|
113
|
+
subprocess.check_call(
|
114
|
+
[
|
115
|
+
sys.executable,
|
116
|
+
"-m",
|
117
|
+
"pip",
|
118
|
+
"install",
|
119
|
+
"supabase",
|
120
|
+
]
|
121
|
+
)
|
122
|
+
print("✅ Supabase installed successfully!")
|
123
|
+
|
124
|
+
# Try importing again
|
125
|
+
from supabase import Client, create_client
|
126
|
+
|
127
|
+
self.supabase_client = Client
|
128
|
+
self.create_client = create_client
|
129
|
+
self.supabase_available = True
|
130
|
+
print("✅ Supabase loaded successfully!")
|
131
|
+
|
132
|
+
except Exception as e:
|
133
|
+
self.supabase_available = False
|
134
|
+
if logger:
|
135
|
+
logger.error(
|
136
|
+
f"Failed to auto-install Supabase. Please install manually with 'pip install supabase': {e}"
|
137
|
+
)
|
138
|
+
raise ImportError(
|
139
|
+
f"Failed to auto-install Supabase. Please install manually with 'pip install supabase': {e}"
|
140
|
+
)
|
141
|
+
|
142
|
+
# Store initialization parameters - BaseCommunication.__init__ is just pass
|
143
|
+
self.system_prompt = system_prompt
|
144
|
+
self.time_enabled = time_enabled
|
145
|
+
self.autosave = autosave
|
146
|
+
self.save_filepath = save_filepath
|
147
|
+
self.tokenizer = tokenizer
|
148
|
+
self.context_length = context_length
|
149
|
+
self.rules = rules
|
150
|
+
self.custom_rules_prompt = custom_rules_prompt
|
151
|
+
self.user = user
|
152
|
+
self.save_as_yaml_on_export = save_as_yaml
|
153
|
+
self.save_as_json_on_export = save_as_json_bool
|
154
|
+
self.calculate_token_count = token_count
|
155
|
+
self.cache_enabled = cache_enabled
|
156
|
+
|
157
|
+
self.supabase_url = supabase_url
|
158
|
+
self.supabase_key = supabase_key
|
159
|
+
self.table_name = table_name
|
160
|
+
self.enable_timestamps = (
|
161
|
+
enable_timestamps # DB handles actual timestamping
|
162
|
+
)
|
163
|
+
self.enable_logging = enable_logging
|
164
|
+
self.use_loguru = use_loguru and LOGURU_AVAILABLE
|
165
|
+
self.max_retries = max_retries
|
166
|
+
|
167
|
+
# Setup logging
|
168
|
+
if self.enable_logging:
|
169
|
+
if self.use_loguru and logger:
|
170
|
+
self.logger = logger
|
171
|
+
else:
|
172
|
+
self.logger = logging.getLogger(__name__)
|
173
|
+
if not self.logger.handlers:
|
174
|
+
handler = logging.StreamHandler()
|
175
|
+
formatter = logging.Formatter(
|
176
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
177
|
+
)
|
178
|
+
handler.setFormatter(formatter)
|
179
|
+
self.logger.addHandler(handler)
|
180
|
+
self.logger.setLevel(logging.INFO)
|
181
|
+
else:
|
182
|
+
# Create a null logger that does nothing
|
183
|
+
self.logger = logging.getLogger(__name__)
|
184
|
+
self.logger.addHandler(logging.NullHandler())
|
185
|
+
|
186
|
+
self.current_conversation_id: Optional[str] = None
|
187
|
+
self._lock = (
|
188
|
+
threading.Lock()
|
189
|
+
) # For thread-safe operations if any (e.g. token calculation)
|
190
|
+
|
191
|
+
try:
|
192
|
+
self.client = self.create_client(
|
193
|
+
supabase_url, supabase_key
|
194
|
+
)
|
195
|
+
if self.enable_logging:
|
196
|
+
self.logger.info(
|
197
|
+
f"Successfully initialized Supabase client for URL: {supabase_url}"
|
198
|
+
)
|
199
|
+
except Exception as e:
|
200
|
+
if self.enable_logging:
|
201
|
+
self.logger.error(
|
202
|
+
f"Failed to initialize Supabase client: {e}"
|
203
|
+
)
|
204
|
+
raise SupabaseConnectionError(
|
205
|
+
f"Failed to connect to Supabase: {e}"
|
206
|
+
)
|
207
|
+
|
208
|
+
self._init_db() # Verifies table existence
|
209
|
+
self.start_new_conversation() # Initializes a conversation ID
|
210
|
+
|
211
|
+
# Add initial prompts if provided
|
212
|
+
if self.system_prompt:
|
213
|
+
self.add(
|
214
|
+
role="system",
|
215
|
+
content=self.system_prompt,
|
216
|
+
message_type=MessageType.SYSTEM,
|
217
|
+
)
|
218
|
+
if self.rules:
|
219
|
+
# Assuming rules are spoken by the system or user based on context
|
220
|
+
self.add(
|
221
|
+
role="system",
|
222
|
+
content=self.rules,
|
223
|
+
message_type=MessageType.SYSTEM,
|
224
|
+
)
|
225
|
+
if self.custom_rules_prompt:
|
226
|
+
self.add(
|
227
|
+
role=self.user,
|
228
|
+
content=self.custom_rules_prompt,
|
229
|
+
message_type=MessageType.USER,
|
230
|
+
)
|
231
|
+
|
232
|
+
def _init_db(self):
|
233
|
+
"""
|
234
|
+
Initialize the database and create necessary tables.
|
235
|
+
Creates the table if it doesn't exist, similar to SQLite implementation.
|
236
|
+
"""
|
237
|
+
# First, try to create the table if it doesn't exist
|
238
|
+
try:
|
239
|
+
# Use Supabase RPC to execute raw SQL for table creation
|
240
|
+
create_table_sql = f"""
|
241
|
+
CREATE TABLE IF NOT EXISTS {self.table_name} (
|
242
|
+
id BIGSERIAL PRIMARY KEY,
|
243
|
+
conversation_id TEXT NOT NULL,
|
244
|
+
role TEXT NOT NULL,
|
245
|
+
content TEXT NOT NULL,
|
246
|
+
timestamp TIMESTAMPTZ DEFAULT NOW(),
|
247
|
+
message_type TEXT,
|
248
|
+
metadata JSONB,
|
249
|
+
token_count INTEGER,
|
250
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
251
|
+
);
|
252
|
+
"""
|
253
|
+
|
254
|
+
# Try to create index as well
|
255
|
+
|
256
|
+
# Attempt to create table using RPC function
|
257
|
+
# Note: This requires a stored procedure to be created in Supabase
|
258
|
+
# If RPC is not available, we'll fall back to checking if table exists
|
259
|
+
try:
|
260
|
+
# Try using a custom RPC function if available
|
261
|
+
self.client.rpc(
|
262
|
+
"exec_sql", {"sql": create_table_sql}
|
263
|
+
).execute()
|
264
|
+
if self.enable_logging:
|
265
|
+
self.logger.info(
|
266
|
+
f"Successfully created or verified table '{self.table_name}' using RPC."
|
267
|
+
)
|
268
|
+
except Exception as rpc_error:
|
269
|
+
if self.enable_logging:
|
270
|
+
self.logger.debug(
|
271
|
+
f"RPC table creation failed (expected if no custom function): {rpc_error}"
|
272
|
+
)
|
273
|
+
|
274
|
+
# Fallback: Try to verify table exists, if not provide helpful error
|
275
|
+
try:
|
276
|
+
response = (
|
277
|
+
self.client.table(self.table_name)
|
278
|
+
.select("id")
|
279
|
+
.limit(1)
|
280
|
+
.execute()
|
281
|
+
)
|
282
|
+
if (
|
283
|
+
response.error
|
284
|
+
and "does not exist"
|
285
|
+
in str(response.error).lower()
|
286
|
+
):
|
287
|
+
# Table doesn't exist, try alternative creation method
|
288
|
+
self._create_table_fallback()
|
289
|
+
elif response.error:
|
290
|
+
raise SupabaseOperationError(
|
291
|
+
f"Error accessing table: {response.error.message}"
|
292
|
+
)
|
293
|
+
else:
|
294
|
+
if self.enable_logging:
|
295
|
+
self.logger.info(
|
296
|
+
f"Successfully verified existing table '{self.table_name}'."
|
297
|
+
)
|
298
|
+
except Exception as table_check_error:
|
299
|
+
if (
|
300
|
+
"does not exist"
|
301
|
+
in str(table_check_error).lower()
|
302
|
+
or "relation"
|
303
|
+
in str(table_check_error).lower()
|
304
|
+
):
|
305
|
+
# Table definitely doesn't exist, provide creation instructions
|
306
|
+
self._handle_missing_table()
|
307
|
+
else:
|
308
|
+
raise SupabaseOperationError(
|
309
|
+
f"Failed to access or create table: {table_check_error}"
|
310
|
+
)
|
311
|
+
|
312
|
+
except Exception as e:
|
313
|
+
if self.enable_logging:
|
314
|
+
self.logger.error(
|
315
|
+
f"Database initialization failed: {e}"
|
316
|
+
)
|
317
|
+
raise SupabaseOperationError(
|
318
|
+
f"Failed to initialize database: {e}"
|
319
|
+
)
|
320
|
+
|
321
|
+
def _create_table_fallback(self):
|
322
|
+
"""
|
323
|
+
Fallback method to create table when RPC is not available.
|
324
|
+
Attempts to use Supabase's admin API or provides clear instructions.
|
325
|
+
"""
|
326
|
+
try:
|
327
|
+
# Try using the admin API if available (requires service role key)
|
328
|
+
# This might work if the user is using a service role key
|
329
|
+
admin_sql = f"""
|
330
|
+
CREATE TABLE IF NOT EXISTS {self.table_name} (
|
331
|
+
id BIGSERIAL PRIMARY KEY,
|
332
|
+
conversation_id TEXT NOT NULL,
|
333
|
+
role TEXT NOT NULL,
|
334
|
+
content TEXT NOT NULL,
|
335
|
+
timestamp TIMESTAMPTZ DEFAULT NOW(),
|
336
|
+
message_type TEXT,
|
337
|
+
metadata JSONB,
|
338
|
+
token_count INTEGER,
|
339
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
340
|
+
);
|
341
|
+
CREATE INDEX IF NOT EXISTS idx_{self.table_name}_conversation_id
|
342
|
+
ON {self.table_name} (conversation_id);
|
343
|
+
"""
|
344
|
+
|
345
|
+
# Note: This might not work with all Supabase configurations
|
346
|
+
# but we attempt it anyway
|
347
|
+
if hasattr(self.client, "postgrest") and hasattr(
|
348
|
+
self.client.postgrest, "rpc"
|
349
|
+
):
|
350
|
+
self.client.postgrest.rpc(
|
351
|
+
"exec_sql", {"query": admin_sql}
|
352
|
+
).execute()
|
353
|
+
if self.enable_logging:
|
354
|
+
self.logger.info(
|
355
|
+
f"Successfully created table '{self.table_name}' using admin API."
|
356
|
+
)
|
357
|
+
return
|
358
|
+
except Exception as e:
|
359
|
+
if self.enable_logging:
|
360
|
+
self.logger.debug(
|
361
|
+
f"Admin API table creation failed: {e}"
|
362
|
+
)
|
363
|
+
|
364
|
+
# If all else fails, call the missing table handler
|
365
|
+
self._handle_missing_table()
|
366
|
+
|
367
|
+
def _handle_missing_table(self):
|
368
|
+
"""
|
369
|
+
Handle the case where the table doesn't exist and can't be created automatically.
|
370
|
+
Provides clear instructions for manual table creation.
|
371
|
+
"""
|
372
|
+
table_creation_sql = f"""
|
373
|
+
-- Run this SQL in your Supabase SQL Editor to create the required table:
|
374
|
+
|
375
|
+
CREATE TABLE IF NOT EXISTS {self.table_name} (
|
376
|
+
id BIGSERIAL PRIMARY KEY,
|
377
|
+
conversation_id TEXT NOT NULL,
|
378
|
+
role TEXT NOT NULL,
|
379
|
+
content TEXT NOT NULL,
|
380
|
+
timestamp TIMESTAMPTZ DEFAULT NOW(),
|
381
|
+
message_type TEXT,
|
382
|
+
metadata JSONB,
|
383
|
+
token_count INTEGER,
|
384
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
385
|
+
);
|
386
|
+
|
387
|
+
-- Create index for better query performance:
|
388
|
+
CREATE INDEX IF NOT EXISTS idx_{self.table_name}_conversation_id
|
389
|
+
ON {self.table_name} (conversation_id);
|
390
|
+
|
391
|
+
-- Optional: Enable Row Level Security (RLS) for production:
|
392
|
+
ALTER TABLE {self.table_name} ENABLE ROW LEVEL SECURITY;
|
393
|
+
|
394
|
+
-- Optional: Create RLS policy (customize according to your needs):
|
395
|
+
CREATE POLICY "Users can manage their own conversations" ON {self.table_name}
|
396
|
+
FOR ALL USING (true); -- Adjust this policy based on your security requirements
|
397
|
+
"""
|
398
|
+
|
399
|
+
error_msg = (
|
400
|
+
f"Table '{self.table_name}' does not exist in your Supabase database and cannot be created automatically. "
|
401
|
+
f"Please create it manually by running the following SQL in your Supabase SQL Editor:\n\n{table_creation_sql}\n\n"
|
402
|
+
f"Alternatively, you can create a custom RPC function in Supabase to enable automatic table creation. "
|
403
|
+
f"Visit your Supabase dashboard > SQL Editor and create this function:\n\n"
|
404
|
+
f"CREATE OR REPLACE FUNCTION exec_sql(sql TEXT)\n"
|
405
|
+
f"RETURNS TEXT AS $$\n"
|
406
|
+
f"BEGIN\n"
|
407
|
+
f" EXECUTE sql;\n"
|
408
|
+
f" RETURN 'SUCCESS';\n"
|
409
|
+
f"END;\n"
|
410
|
+
f"$$ LANGUAGE plpgsql SECURITY DEFINER;\n\n"
|
411
|
+
f"After creating either the table or the RPC function, retry initializing the SupabaseConversation."
|
412
|
+
)
|
413
|
+
|
414
|
+
if self.enable_logging:
|
415
|
+
self.logger.error(error_msg)
|
416
|
+
raise SupabaseOperationError(error_msg)
|
417
|
+
|
418
|
+
def _handle_api_response(
|
419
|
+
self, response, operation_name: str = "Supabase operation"
|
420
|
+
):
|
421
|
+
"""Handles Supabase API response, checking for errors and returning data."""
|
422
|
+
# The new supabase-py client structure: response has .data and .count attributes
|
423
|
+
# Errors are raised as exceptions rather than being in response.error
|
424
|
+
try:
|
425
|
+
if hasattr(response, "data"):
|
426
|
+
# Return the data, which could be None, a list, or a dict
|
427
|
+
return response.data
|
428
|
+
else:
|
429
|
+
# Fallback for older response structures or direct data
|
430
|
+
return response
|
431
|
+
except Exception as e:
|
432
|
+
if self.enable_logging:
|
433
|
+
self.logger.error(f"{operation_name} failed: {e}")
|
434
|
+
raise SupabaseOperationError(
|
435
|
+
f"{operation_name} failed: {e}"
|
436
|
+
)
|
437
|
+
|
438
|
+
def _serialize_content(
|
439
|
+
self, content: Union[str, dict, list]
|
440
|
+
) -> str:
|
441
|
+
"""Serializes content to JSON string if it's a dict or list."""
|
442
|
+
if isinstance(content, (dict, list)):
|
443
|
+
return json.dumps(content, cls=DateTimeEncoder)
|
444
|
+
return str(content)
|
445
|
+
|
446
|
+
def _deserialize_content(
|
447
|
+
self, content_str: str
|
448
|
+
) -> Union[str, dict, list]:
|
449
|
+
"""Deserializes content from JSON string if it looks like JSON. More robust approach."""
|
450
|
+
if not content_str:
|
451
|
+
return content_str
|
452
|
+
|
453
|
+
# Always try to parse as JSON first, fall back to string
|
454
|
+
try:
|
455
|
+
return json.loads(content_str)
|
456
|
+
except (json.JSONDecodeError, TypeError):
|
457
|
+
# Not valid JSON, return as string
|
458
|
+
return content_str
|
459
|
+
|
460
|
+
def _serialize_metadata(
|
461
|
+
self, metadata: Optional[Dict]
|
462
|
+
) -> Optional[str]:
|
463
|
+
"""Serializes metadata dict to JSON string using simplified encoder."""
|
464
|
+
if metadata is None:
|
465
|
+
return None
|
466
|
+
try:
|
467
|
+
return json.dumps(
|
468
|
+
metadata, default=str, ensure_ascii=False
|
469
|
+
)
|
470
|
+
except (TypeError, ValueError) as e:
|
471
|
+
if self.enable_logging:
|
472
|
+
self.logger.warning(
|
473
|
+
f"Failed to serialize metadata: {e}"
|
474
|
+
)
|
475
|
+
return None
|
476
|
+
|
477
|
+
def _deserialize_metadata(
|
478
|
+
self, metadata_str: Optional[str]
|
479
|
+
) -> Optional[Dict]:
|
480
|
+
"""Deserializes metadata from JSON string with better error handling."""
|
481
|
+
if metadata_str is None:
|
482
|
+
return None
|
483
|
+
try:
|
484
|
+
return json.loads(metadata_str)
|
485
|
+
except (json.JSONDecodeError, TypeError) as e:
|
486
|
+
if self.enable_logging:
|
487
|
+
self.logger.warning(
|
488
|
+
f"Failed to deserialize metadata: {metadata_str[:50]}... Error: {e}"
|
489
|
+
)
|
490
|
+
return None
|
491
|
+
|
492
|
+
def _generate_conversation_id(self) -> str:
|
493
|
+
"""Generate a unique conversation ID using UUID and timestamp."""
|
494
|
+
timestamp = datetime.datetime.now(
|
495
|
+
datetime.timezone.utc
|
496
|
+
).strftime("%Y%m%d_%H%M%S_%f")
|
497
|
+
unique_id = str(uuid.uuid4())[:8]
|
498
|
+
return f"conv_{timestamp}_{unique_id}"
|
499
|
+
|
500
|
+
def start_new_conversation(self) -> str:
|
501
|
+
"""Starts a new conversation and returns its ID."""
|
502
|
+
self.current_conversation_id = (
|
503
|
+
self._generate_conversation_id()
|
504
|
+
)
|
505
|
+
self.logger.info(
|
506
|
+
f"Started new conversation with ID: {self.current_conversation_id}"
|
507
|
+
)
|
508
|
+
return self.current_conversation_id
|
509
|
+
|
510
|
+
def add(
|
511
|
+
self,
|
512
|
+
role: str,
|
513
|
+
content: Union[str, dict, list],
|
514
|
+
message_type: Optional[MessageType] = None,
|
515
|
+
metadata: Optional[Dict] = None,
|
516
|
+
token_count: Optional[int] = None,
|
517
|
+
) -> int:
|
518
|
+
"""Add a message to the current conversation history in Supabase."""
|
519
|
+
if self.current_conversation_id is None:
|
520
|
+
self.start_new_conversation()
|
521
|
+
|
522
|
+
serialized_content = self._serialize_content(content)
|
523
|
+
current_timestamp_iso = datetime.datetime.now(
|
524
|
+
datetime.timezone.utc
|
525
|
+
).isoformat()
|
526
|
+
|
527
|
+
message_data = {
|
528
|
+
"conversation_id": self.current_conversation_id,
|
529
|
+
"role": role,
|
530
|
+
"content": serialized_content,
|
531
|
+
"timestamp": current_timestamp_iso, # Supabase will use its default if not provided / column allows NULL
|
532
|
+
"message_type": (
|
533
|
+
message_type.value if message_type else None
|
534
|
+
),
|
535
|
+
"metadata": self._serialize_metadata(metadata),
|
536
|
+
# token_count handled below
|
537
|
+
}
|
538
|
+
|
539
|
+
# Calculate token_count if enabled and not provided
|
540
|
+
if (
|
541
|
+
self.calculate_token_count
|
542
|
+
and token_count is None
|
543
|
+
and self.tokenizer
|
544
|
+
):
|
545
|
+
try:
|
546
|
+
# For now, do this synchronously. For long content, consider async/threading.
|
547
|
+
message_data["token_count"] = (
|
548
|
+
self.tokenizer.count_tokens(str(content))
|
549
|
+
)
|
550
|
+
except Exception as e:
|
551
|
+
if self.enable_logging:
|
552
|
+
self.logger.warning(
|
553
|
+
f"Failed to count tokens for content: {e}"
|
554
|
+
)
|
555
|
+
elif token_count is not None:
|
556
|
+
message_data["token_count"] = token_count
|
557
|
+
|
558
|
+
# Filter out None values to let Supabase handle defaults or NULLs appropriately
|
559
|
+
message_to_insert = {
|
560
|
+
k: v for k, v in message_data.items() if v is not None
|
561
|
+
}
|
562
|
+
|
563
|
+
try:
|
564
|
+
response = (
|
565
|
+
self.client.table(self.table_name)
|
566
|
+
.insert(message_to_insert)
|
567
|
+
.execute()
|
568
|
+
)
|
569
|
+
data = self._handle_api_response(response, "add_message")
|
570
|
+
if data and len(data) > 0 and "id" in data[0]:
|
571
|
+
inserted_id = data[0]["id"]
|
572
|
+
if self.enable_logging:
|
573
|
+
self.logger.debug(
|
574
|
+
f"Added message with ID {inserted_id} to conversation {self.current_conversation_id}"
|
575
|
+
)
|
576
|
+
return inserted_id
|
577
|
+
if self.enable_logging:
|
578
|
+
self.logger.error(
|
579
|
+
f"Failed to retrieve ID for inserted message in conversation {self.current_conversation_id}"
|
580
|
+
)
|
581
|
+
raise SupabaseOperationError(
|
582
|
+
"Failed to retrieve ID for inserted message."
|
583
|
+
)
|
584
|
+
except Exception as e:
|
585
|
+
if self.enable_logging:
|
586
|
+
self.logger.error(
|
587
|
+
f"Error adding message to Supabase: {e}"
|
588
|
+
)
|
589
|
+
raise SupabaseOperationError(f"Error adding message: {e}")
|
590
|
+
|
591
|
+
def batch_add(self, messages: List[Message]) -> List[int]:
|
592
|
+
"""Add multiple messages to the current conversation history in Supabase."""
|
593
|
+
if self.current_conversation_id is None:
|
594
|
+
self.start_new_conversation()
|
595
|
+
|
596
|
+
messages_to_insert = []
|
597
|
+
for msg_obj in messages:
|
598
|
+
serialized_content = self._serialize_content(
|
599
|
+
msg_obj.content
|
600
|
+
)
|
601
|
+
current_timestamp_iso = (
|
602
|
+
msg_obj.timestamp
|
603
|
+
or datetime.datetime.now(
|
604
|
+
datetime.timezone.utc
|
605
|
+
).isoformat()
|
606
|
+
)
|
607
|
+
|
608
|
+
msg_data = {
|
609
|
+
"conversation_id": self.current_conversation_id,
|
610
|
+
"role": msg_obj.role,
|
611
|
+
"content": serialized_content,
|
612
|
+
"timestamp": current_timestamp_iso,
|
613
|
+
"message_type": (
|
614
|
+
msg_obj.message_type.value
|
615
|
+
if msg_obj.message_type
|
616
|
+
else None
|
617
|
+
),
|
618
|
+
"metadata": self._serialize_metadata(
|
619
|
+
msg_obj.metadata
|
620
|
+
),
|
621
|
+
}
|
622
|
+
|
623
|
+
# Token count
|
624
|
+
current_token_count = msg_obj.token_count
|
625
|
+
if (
|
626
|
+
self.calculate_token_count
|
627
|
+
and current_token_count is None
|
628
|
+
and self.tokenizer
|
629
|
+
):
|
630
|
+
try:
|
631
|
+
current_token_count = self.tokenizer.count_tokens(
|
632
|
+
str(msg_obj.content)
|
633
|
+
)
|
634
|
+
except Exception as e:
|
635
|
+
self.logger.warning(
|
636
|
+
f"Failed to count tokens for batch message: {e}"
|
637
|
+
)
|
638
|
+
if current_token_count is not None:
|
639
|
+
msg_data["token_count"] = current_token_count
|
640
|
+
|
641
|
+
messages_to_insert.append(
|
642
|
+
{k: v for k, v in msg_data.items() if v is not None}
|
643
|
+
)
|
644
|
+
|
645
|
+
if not messages_to_insert:
|
646
|
+
return []
|
647
|
+
|
648
|
+
try:
|
649
|
+
response = (
|
650
|
+
self.client.table(self.table_name)
|
651
|
+
.insert(messages_to_insert)
|
652
|
+
.execute()
|
653
|
+
)
|
654
|
+
data = self._handle_api_response(
|
655
|
+
response, "batch_add_messages"
|
656
|
+
)
|
657
|
+
inserted_ids = [
|
658
|
+
item["id"] for item in data if "id" in item
|
659
|
+
]
|
660
|
+
if len(inserted_ids) != len(messages_to_insert):
|
661
|
+
self.logger.warning(
|
662
|
+
"Mismatch in expected and inserted message counts during batch_add."
|
663
|
+
)
|
664
|
+
self.logger.debug(
|
665
|
+
f"Batch added {len(inserted_ids)} messages to conversation {self.current_conversation_id}"
|
666
|
+
)
|
667
|
+
return inserted_ids
|
668
|
+
except Exception as e:
|
669
|
+
self.logger.error(
|
670
|
+
f"Error batch adding messages to Supabase: {e}"
|
671
|
+
)
|
672
|
+
raise SupabaseOperationError(
|
673
|
+
f"Error batch adding messages: {e}"
|
674
|
+
)
|
675
|
+
|
676
|
+
def _format_row_to_dict(self, row: Dict) -> Dict:
|
677
|
+
"""Helper to format a raw row from Supabase to our standard message dict."""
|
678
|
+
formatted_message = {
|
679
|
+
"id": row.get("id"),
|
680
|
+
"role": row.get("role"),
|
681
|
+
"content": self._deserialize_content(
|
682
|
+
row.get("content", "")
|
683
|
+
),
|
684
|
+
"timestamp": row.get("timestamp"),
|
685
|
+
"message_type": row.get("message_type"),
|
686
|
+
"metadata": self._deserialize_metadata(
|
687
|
+
row.get("metadata")
|
688
|
+
),
|
689
|
+
"token_count": row.get("token_count"),
|
690
|
+
"conversation_id": row.get("conversation_id"),
|
691
|
+
"created_at": row.get("created_at"),
|
692
|
+
}
|
693
|
+
# Clean None values from the root, but keep them within deserialized content/metadata
|
694
|
+
return {
|
695
|
+
k: v
|
696
|
+
for k, v in formatted_message.items()
|
697
|
+
if v is not None
|
698
|
+
or k in ["metadata", "token_count", "message_type"]
|
699
|
+
}
|
700
|
+
|
701
|
+
def get_messages(
|
702
|
+
self,
|
703
|
+
limit: Optional[int] = None,
|
704
|
+
offset: Optional[int] = None,
|
705
|
+
) -> List[Dict]:
|
706
|
+
"""Get messages from the current conversation with optional pagination."""
|
707
|
+
if self.current_conversation_id is None:
|
708
|
+
return []
|
709
|
+
try:
|
710
|
+
query = (
|
711
|
+
self.client.table(self.table_name)
|
712
|
+
.select("*")
|
713
|
+
.eq("conversation_id", self.current_conversation_id)
|
714
|
+
.order("timestamp", desc=False)
|
715
|
+
) # Assuming 'timestamp' or 'id' for ordering
|
716
|
+
|
717
|
+
if limit is not None:
|
718
|
+
query = query.limit(limit)
|
719
|
+
if offset is not None:
|
720
|
+
query = query.offset(offset)
|
721
|
+
|
722
|
+
response = query.execute()
|
723
|
+
data = self._handle_api_response(response, "get_messages")
|
724
|
+
return [self._format_row_to_dict(row) for row in data]
|
725
|
+
except Exception as e:
|
726
|
+
self.logger.error(
|
727
|
+
f"Error getting messages from Supabase: {e}"
|
728
|
+
)
|
729
|
+
raise SupabaseOperationError(
|
730
|
+
f"Error getting messages: {e}"
|
731
|
+
)
|
732
|
+
|
733
|
+
def get_str(self) -> str:
|
734
|
+
"""Get the current conversation history as a formatted string."""
|
735
|
+
messages_dict = self.get_messages()
|
736
|
+
conv_str = []
|
737
|
+
for msg in messages_dict:
|
738
|
+
ts_prefix = (
|
739
|
+
f"[{msg['timestamp']}] "
|
740
|
+
if msg.get("timestamp") and self.time_enabled
|
741
|
+
else ""
|
742
|
+
)
|
743
|
+
# Content might be dict/list if deserialized
|
744
|
+
content_display = msg["content"]
|
745
|
+
if isinstance(content_display, (dict, list)):
|
746
|
+
content_display = json.dumps(
|
747
|
+
content_display, indent=2, cls=DateTimeEncoder
|
748
|
+
)
|
749
|
+
conv_str.append(
|
750
|
+
f"{ts_prefix}{msg['role']}: {content_display}"
|
751
|
+
)
|
752
|
+
return "\n".join(conv_str)
|
753
|
+
|
754
|
+
def display_conversation(self, detailed: bool = False):
|
755
|
+
"""Display the conversation history."""
|
756
|
+
# `detailed` flag might be used for more verbose printing if needed
|
757
|
+
print(self.get_str())
|
758
|
+
|
759
|
+
def delete(self, index: str):
|
760
|
+
"""Delete a message from the conversation history by its primary key 'id'."""
|
761
|
+
if self.current_conversation_id is None:
|
762
|
+
if self.enable_logging:
|
763
|
+
self.logger.warning(
|
764
|
+
"Cannot delete message: No current conversation."
|
765
|
+
)
|
766
|
+
return
|
767
|
+
|
768
|
+
try:
|
769
|
+
# Handle both string and int message IDs
|
770
|
+
try:
|
771
|
+
message_id = int(index)
|
772
|
+
except ValueError:
|
773
|
+
if self.enable_logging:
|
774
|
+
self.logger.error(
|
775
|
+
f"Invalid message ID for delete: {index}. Must be an integer."
|
776
|
+
)
|
777
|
+
raise ValueError(
|
778
|
+
f"Invalid message ID for delete: {index}. Must be an integer."
|
779
|
+
)
|
780
|
+
|
781
|
+
response = (
|
782
|
+
self.client.table(self.table_name)
|
783
|
+
.delete()
|
784
|
+
.eq("id", message_id)
|
785
|
+
.eq("conversation_id", self.current_conversation_id)
|
786
|
+
.execute()
|
787
|
+
)
|
788
|
+
self._handle_api_response(
|
789
|
+
response, f"delete_message (id: {message_id})"
|
790
|
+
)
|
791
|
+
if self.enable_logging:
|
792
|
+
self.logger.info(
|
793
|
+
f"Deleted message with ID {message_id} from conversation {self.current_conversation_id}"
|
794
|
+
)
|
795
|
+
except Exception as e:
|
796
|
+
if self.enable_logging:
|
797
|
+
self.logger.error(
|
798
|
+
f"Error deleting message ID {index} from Supabase: {e}"
|
799
|
+
)
|
800
|
+
raise SupabaseOperationError(
|
801
|
+
f"Error deleting message ID {index}: {e}"
|
802
|
+
)
|
803
|
+
|
804
|
+
def update(
|
805
|
+
self, index: str, role: str, content: Union[str, dict]
|
806
|
+
):
|
807
|
+
"""Update a message in the conversation history. Matches BaseCommunication signature exactly."""
|
808
|
+
# Use the flexible internal method
|
809
|
+
return self._update_flexible(
|
810
|
+
index=index, role=role, content=content
|
811
|
+
)
|
812
|
+
|
813
|
+
def _update_flexible(
|
814
|
+
self,
|
815
|
+
index: Union[str, int],
|
816
|
+
role: Optional[str] = None,
|
817
|
+
content: Optional[Union[str, dict]] = None,
|
818
|
+
metadata: Optional[Dict] = None,
|
819
|
+
) -> bool:
|
820
|
+
"""Internal flexible update method. Returns True if successful, False otherwise."""
|
821
|
+
if self.current_conversation_id is None:
|
822
|
+
if self.enable_logging:
|
823
|
+
self.logger.warning(
|
824
|
+
"Cannot update message: No current conversation."
|
825
|
+
)
|
826
|
+
return False
|
827
|
+
|
828
|
+
# Handle both string and int message IDs
|
829
|
+
try:
|
830
|
+
if isinstance(index, str):
|
831
|
+
message_id = int(index)
|
832
|
+
else:
|
833
|
+
message_id = index
|
834
|
+
except ValueError:
|
835
|
+
if self.enable_logging:
|
836
|
+
self.logger.error(
|
837
|
+
f"Invalid message ID for update: {index}. Must be an integer."
|
838
|
+
)
|
839
|
+
return False
|
840
|
+
|
841
|
+
update_data = {}
|
842
|
+
if role is not None:
|
843
|
+
update_data["role"] = role
|
844
|
+
if content is not None:
|
845
|
+
update_data["content"] = self._serialize_content(content)
|
846
|
+
if self.calculate_token_count and self.tokenizer:
|
847
|
+
try:
|
848
|
+
update_data["token_count"] = (
|
849
|
+
self.tokenizer.count_tokens(str(content))
|
850
|
+
)
|
851
|
+
except Exception as e:
|
852
|
+
if self.enable_logging:
|
853
|
+
self.logger.warning(
|
854
|
+
f"Failed to count tokens for updated content: {e}"
|
855
|
+
)
|
856
|
+
if (
|
857
|
+
metadata is not None
|
858
|
+
): # Allows setting metadata to null by passing {} then serializing
|
859
|
+
update_data["metadata"] = self._serialize_metadata(
|
860
|
+
metadata
|
861
|
+
)
|
862
|
+
|
863
|
+
if not update_data:
|
864
|
+
if self.enable_logging:
|
865
|
+
self.logger.info(
|
866
|
+
"No fields provided to update for message."
|
867
|
+
)
|
868
|
+
return False
|
869
|
+
|
870
|
+
try:
|
871
|
+
response = (
|
872
|
+
self.client.table(self.table_name)
|
873
|
+
.update(update_data)
|
874
|
+
.eq("id", message_id)
|
875
|
+
.eq("conversation_id", self.current_conversation_id)
|
876
|
+
.execute()
|
877
|
+
)
|
878
|
+
|
879
|
+
data = self._handle_api_response(
|
880
|
+
response, f"update_message (id: {message_id})"
|
881
|
+
)
|
882
|
+
|
883
|
+
# Check if any rows were actually updated
|
884
|
+
if data and len(data) > 0:
|
885
|
+
if self.enable_logging:
|
886
|
+
self.logger.info(
|
887
|
+
f"Updated message with ID {message_id} in conversation {self.current_conversation_id}"
|
888
|
+
)
|
889
|
+
return True
|
890
|
+
else:
|
891
|
+
if self.enable_logging:
|
892
|
+
self.logger.warning(
|
893
|
+
f"No message found with ID {message_id} in conversation {self.current_conversation_id}"
|
894
|
+
)
|
895
|
+
return False
|
896
|
+
|
897
|
+
except Exception as e:
|
898
|
+
if self.enable_logging:
|
899
|
+
self.logger.error(
|
900
|
+
f"Error updating message ID {message_id} in Supabase: {e}"
|
901
|
+
)
|
902
|
+
return False
|
903
|
+
|
904
|
+
def query(self, index: str) -> Dict:
|
905
|
+
"""Query a message in the conversation history by its primary key 'id'. Returns empty dict if not found to match BaseCommunication signature."""
|
906
|
+
if self.current_conversation_id is None:
|
907
|
+
return {}
|
908
|
+
try:
|
909
|
+
# Handle both string and int message IDs
|
910
|
+
try:
|
911
|
+
message_id = int(index)
|
912
|
+
except ValueError:
|
913
|
+
if self.enable_logging:
|
914
|
+
self.logger.warning(
|
915
|
+
f"Invalid message ID for query: {index}. Must be an integer."
|
916
|
+
)
|
917
|
+
return {}
|
918
|
+
|
919
|
+
response = (
|
920
|
+
self.client.table(self.table_name)
|
921
|
+
.select("*")
|
922
|
+
.eq("id", message_id)
|
923
|
+
.eq("conversation_id", self.current_conversation_id)
|
924
|
+
.maybe_single()
|
925
|
+
.execute()
|
926
|
+
) # maybe_single returns one record or None
|
927
|
+
|
928
|
+
data = self._handle_api_response(
|
929
|
+
response, f"query_message (id: {message_id})"
|
930
|
+
)
|
931
|
+
if data:
|
932
|
+
return self._format_row_to_dict(data)
|
933
|
+
return {}
|
934
|
+
except Exception as e:
|
935
|
+
if self.enable_logging:
|
936
|
+
self.logger.error(
|
937
|
+
f"Error querying message ID {index} from Supabase: {e}"
|
938
|
+
)
|
939
|
+
return {}
|
940
|
+
|
941
|
+
def query_optional(self, index: str) -> Optional[Dict]:
|
942
|
+
"""Query a message and return None if not found. More precise return type."""
|
943
|
+
result = self.query(index)
|
944
|
+
return result if result else None
|
945
|
+
|
946
|
+
def search(self, keyword: str) -> List[Dict]:
|
947
|
+
"""Search for messages containing a keyword in their content."""
|
948
|
+
if self.current_conversation_id is None:
|
949
|
+
return []
|
950
|
+
try:
|
951
|
+
# PostgREST ilike is case-insensitive
|
952
|
+
response = (
|
953
|
+
self.client.table(self.table_name)
|
954
|
+
.select("*")
|
955
|
+
.eq("conversation_id", self.current_conversation_id)
|
956
|
+
.ilike("content", f"%{keyword}%")
|
957
|
+
.order("timestamp", desc=False)
|
958
|
+
.execute()
|
959
|
+
)
|
960
|
+
data = self._handle_api_response(
|
961
|
+
response, f"search_messages (keyword: {keyword})"
|
962
|
+
)
|
963
|
+
return [self._format_row_to_dict(row) for row in data]
|
964
|
+
except Exception as e:
|
965
|
+
self.logger.error(
|
966
|
+
f"Error searching messages in Supabase: {e}"
|
967
|
+
)
|
968
|
+
raise SupabaseOperationError(
|
969
|
+
f"Error searching messages: {e}"
|
970
|
+
)
|
971
|
+
|
972
|
+
def _export_to_file(self, filename: str, format_type: str):
|
973
|
+
"""Helper to export conversation to JSON or YAML file."""
|
974
|
+
if self.current_conversation_id is None:
|
975
|
+
self.logger.warning("No current conversation to export.")
|
976
|
+
return
|
977
|
+
|
978
|
+
data_to_export = (
|
979
|
+
self.to_dict()
|
980
|
+
) # Gets messages for current_conversation_id
|
981
|
+
try:
|
982
|
+
with open(filename, "w") as f:
|
983
|
+
if format_type == "json":
|
984
|
+
json.dump(
|
985
|
+
data_to_export,
|
986
|
+
f,
|
987
|
+
indent=2,
|
988
|
+
cls=DateTimeEncoder,
|
989
|
+
)
|
990
|
+
elif format_type == "yaml":
|
991
|
+
yaml.dump(data_to_export, f, sort_keys=False)
|
992
|
+
else:
|
993
|
+
raise ValueError(
|
994
|
+
f"Unsupported export format: {format_type}"
|
995
|
+
)
|
996
|
+
self.logger.info(
|
997
|
+
f"Conversation {self.current_conversation_id} exported to {filename} as {format_type}."
|
998
|
+
)
|
999
|
+
except Exception as e:
|
1000
|
+
self.logger.error(
|
1001
|
+
f"Failed to export conversation to {format_type}: {e}"
|
1002
|
+
)
|
1003
|
+
raise
|
1004
|
+
|
1005
|
+
def export_conversation(self, filename: str):
|
1006
|
+
"""Export the current conversation history to a file (JSON or YAML based on init flags)."""
|
1007
|
+
if self.save_as_json_on_export:
|
1008
|
+
self._export_to_file(filename, "json")
|
1009
|
+
elif self.save_as_yaml_on_export: # Default if json is false
|
1010
|
+
self._export_to_file(filename, "yaml")
|
1011
|
+
else: # Fallback if somehow both are false
|
1012
|
+
self._export_to_file(filename, "yaml")
|
1013
|
+
|
1014
|
+
def _import_from_file(self, filename: str, format_type: str):
|
1015
|
+
"""Helper to import conversation from JSON or YAML file."""
|
1016
|
+
try:
|
1017
|
+
with open(filename, "r") as f:
|
1018
|
+
if format_type == "json":
|
1019
|
+
imported_data = json.load(f)
|
1020
|
+
elif format_type == "yaml":
|
1021
|
+
imported_data = yaml.safe_load(f)
|
1022
|
+
else:
|
1023
|
+
raise ValueError(
|
1024
|
+
f"Unsupported import format: {format_type}"
|
1025
|
+
)
|
1026
|
+
|
1027
|
+
if not isinstance(imported_data, list):
|
1028
|
+
raise ValueError(
|
1029
|
+
"Imported data must be a list of messages."
|
1030
|
+
)
|
1031
|
+
|
1032
|
+
# Start a new conversation for the imported data
|
1033
|
+
self.start_new_conversation()
|
1034
|
+
|
1035
|
+
messages_to_batch = []
|
1036
|
+
for msg_data in imported_data:
|
1037
|
+
# Adapt to Message dataclass structure if possible
|
1038
|
+
role = msg_data.get("role")
|
1039
|
+
content = msg_data.get("content")
|
1040
|
+
if role is None or content is None:
|
1041
|
+
self.logger.warning(
|
1042
|
+
f"Skipping message due to missing role/content: {msg_data}"
|
1043
|
+
)
|
1044
|
+
continue
|
1045
|
+
|
1046
|
+
messages_to_batch.append(
|
1047
|
+
Message(
|
1048
|
+
role=role,
|
1049
|
+
content=content,
|
1050
|
+
timestamp=msg_data.get(
|
1051
|
+
"timestamp"
|
1052
|
+
), # Will be handled by batch_add
|
1053
|
+
message_type=(
|
1054
|
+
MessageType(msg_data["message_type"])
|
1055
|
+
if msg_data.get("message_type")
|
1056
|
+
else None
|
1057
|
+
),
|
1058
|
+
metadata=msg_data.get("metadata"),
|
1059
|
+
token_count=msg_data.get("token_count"),
|
1060
|
+
)
|
1061
|
+
)
|
1062
|
+
|
1063
|
+
if messages_to_batch:
|
1064
|
+
self.batch_add(messages_to_batch)
|
1065
|
+
self.logger.info(
|
1066
|
+
f"Conversation imported from {filename} ({format_type}) into new ID {self.current_conversation_id}."
|
1067
|
+
)
|
1068
|
+
|
1069
|
+
except Exception as e:
|
1070
|
+
self.logger.error(
|
1071
|
+
f"Failed to import conversation from {format_type}: {e}"
|
1072
|
+
)
|
1073
|
+
raise
|
1074
|
+
|
1075
|
+
def import_conversation(self, filename: str):
|
1076
|
+
"""Import a conversation history from a file (tries JSON then YAML)."""
|
1077
|
+
try:
|
1078
|
+
if filename.lower().endswith(".json"):
|
1079
|
+
self._import_from_file(filename, "json")
|
1080
|
+
elif filename.lower().endswith((".yaml", ".yml")):
|
1081
|
+
self._import_from_file(filename, "yaml")
|
1082
|
+
else:
|
1083
|
+
# Try JSON first, then YAML as a fallback
|
1084
|
+
try:
|
1085
|
+
self._import_from_file(filename, "json")
|
1086
|
+
except (
|
1087
|
+
json.JSONDecodeError,
|
1088
|
+
ValueError,
|
1089
|
+
): # ValueError if not list
|
1090
|
+
self.logger.info(
|
1091
|
+
f"Failed to import {filename} as JSON, trying YAML."
|
1092
|
+
)
|
1093
|
+
self._import_from_file(filename, "yaml")
|
1094
|
+
except Exception as e: # Catch errors from _import_from_file
|
1095
|
+
raise SupabaseOperationError(
|
1096
|
+
f"Could not import {filename}: {e}"
|
1097
|
+
)
|
1098
|
+
|
1099
|
+
def count_messages_by_role(self) -> Dict[str, int]:
|
1100
|
+
"""Count messages by role for the current conversation."""
|
1101
|
+
if self.current_conversation_id is None:
|
1102
|
+
return {}
|
1103
|
+
try:
|
1104
|
+
# Supabase rpc might be better for direct count, but select + python count is also fine
|
1105
|
+
# For direct DB count: self.client.rpc('count_roles', {'conv_id': self.current_conversation_id}).execute()
|
1106
|
+
messages = (
|
1107
|
+
self.get_messages()
|
1108
|
+
) # Fetches for current_conversation_id
|
1109
|
+
counts = {}
|
1110
|
+
for msg in messages:
|
1111
|
+
role = msg.get("role", "unknown")
|
1112
|
+
counts[role] = counts.get(role, 0) + 1
|
1113
|
+
return counts
|
1114
|
+
except Exception as e:
|
1115
|
+
self.logger.error(f"Error counting messages by role: {e}")
|
1116
|
+
raise SupabaseOperationError(
|
1117
|
+
f"Error counting messages by role: {e}"
|
1118
|
+
)
|
1119
|
+
|
1120
|
+
def return_history_as_string(self) -> str:
|
1121
|
+
"""Return the conversation history as a string."""
|
1122
|
+
return self.get_str()
|
1123
|
+
|
1124
|
+
def clear(self):
|
1125
|
+
"""Clear the current conversation history from Supabase."""
|
1126
|
+
if self.current_conversation_id is None:
|
1127
|
+
self.logger.info("No current conversation to clear.")
|
1128
|
+
return
|
1129
|
+
try:
|
1130
|
+
response = (
|
1131
|
+
self.client.table(self.table_name)
|
1132
|
+
.delete()
|
1133
|
+
.eq("conversation_id", self.current_conversation_id)
|
1134
|
+
.execute()
|
1135
|
+
)
|
1136
|
+
# response.data will be a list of deleted items.
|
1137
|
+
# response.count might be available for delete operations in some supabase-py versions or configurations.
|
1138
|
+
# For now, we assume success if no error.
|
1139
|
+
self._handle_api_response(
|
1140
|
+
response,
|
1141
|
+
f"clear_conversation (id: {self.current_conversation_id})",
|
1142
|
+
)
|
1143
|
+
self.logger.info(
|
1144
|
+
f"Cleared conversation with ID: {self.current_conversation_id}"
|
1145
|
+
)
|
1146
|
+
except Exception as e:
|
1147
|
+
self.logger.error(
|
1148
|
+
f"Error clearing conversation {self.current_conversation_id} from Supabase: {e}"
|
1149
|
+
)
|
1150
|
+
raise SupabaseOperationError(
|
1151
|
+
f"Error clearing conversation: {e}"
|
1152
|
+
)
|
1153
|
+
|
1154
|
+
def to_dict(self) -> List[Dict]:
|
1155
|
+
"""Convert the current conversation history to a list of dictionaries."""
|
1156
|
+
return (
|
1157
|
+
self.get_messages()
|
1158
|
+
) # Already fetches for current_conversation_id
|
1159
|
+
|
1160
|
+
def to_json(self) -> str:
|
1161
|
+
"""Convert the current conversation history to a JSON string."""
|
1162
|
+
return json.dumps(
|
1163
|
+
self.to_dict(), indent=2, cls=DateTimeEncoder
|
1164
|
+
)
|
1165
|
+
|
1166
|
+
def to_yaml(self) -> str:
|
1167
|
+
"""Convert the current conversation history to a YAML string."""
|
1168
|
+
return yaml.dump(self.to_dict(), sort_keys=False)
|
1169
|
+
|
1170
|
+
def save_as_json(self, filename: str):
|
1171
|
+
"""Save the current conversation history as a JSON file."""
|
1172
|
+
self._export_to_file(filename, "json")
|
1173
|
+
|
1174
|
+
def load_from_json(self, filename: str):
|
1175
|
+
"""Load a conversation history from a JSON file into a new conversation."""
|
1176
|
+
self._import_from_file(filename, "json")
|
1177
|
+
|
1178
|
+
def save_as_yaml(self, filename: str):
|
1179
|
+
"""Save the current conversation history as a YAML file."""
|
1180
|
+
self._export_to_file(filename, "yaml")
|
1181
|
+
|
1182
|
+
def load_from_yaml(self, filename: str):
|
1183
|
+
"""Load a conversation history from a YAML file into a new conversation."""
|
1184
|
+
self._import_from_file(filename, "yaml")
|
1185
|
+
|
1186
|
+
def get_last_message(self) -> Optional[Dict]:
|
1187
|
+
"""Get the last message from the current conversation history."""
|
1188
|
+
if self.current_conversation_id is None:
|
1189
|
+
return None
|
1190
|
+
try:
|
1191
|
+
response = (
|
1192
|
+
self.client.table(self.table_name)
|
1193
|
+
.select("*")
|
1194
|
+
.eq("conversation_id", self.current_conversation_id)
|
1195
|
+
.order("timestamp", desc=True)
|
1196
|
+
.limit(1)
|
1197
|
+
.maybe_single()
|
1198
|
+
.execute()
|
1199
|
+
)
|
1200
|
+
data = self._handle_api_response(
|
1201
|
+
response, "get_last_message"
|
1202
|
+
)
|
1203
|
+
return self._format_row_to_dict(data) if data else None
|
1204
|
+
except Exception as e:
|
1205
|
+
self.logger.error(
|
1206
|
+
f"Error getting last message from Supabase: {e}"
|
1207
|
+
)
|
1208
|
+
raise SupabaseOperationError(
|
1209
|
+
f"Error getting last message: {e}"
|
1210
|
+
)
|
1211
|
+
|
1212
|
+
def get_last_message_as_string(self) -> str:
|
1213
|
+
"""Get the last message as a formatted string."""
|
1214
|
+
last_msg = self.get_last_message()
|
1215
|
+
if not last_msg:
|
1216
|
+
return ""
|
1217
|
+
ts_prefix = (
|
1218
|
+
f"[{last_msg['timestamp']}] "
|
1219
|
+
if last_msg.get("timestamp") and self.time_enabled
|
1220
|
+
else ""
|
1221
|
+
)
|
1222
|
+
content_display = last_msg["content"]
|
1223
|
+
if isinstance(content_display, (dict, list)):
|
1224
|
+
content_display = json.dumps(
|
1225
|
+
content_display, cls=DateTimeEncoder
|
1226
|
+
)
|
1227
|
+
return f"{ts_prefix}{last_msg['role']}: {content_display}"
|
1228
|
+
|
1229
|
+
def get_messages_by_role(self, role: str) -> List[Dict]:
|
1230
|
+
"""Get all messages from a specific role in the current conversation."""
|
1231
|
+
if self.current_conversation_id is None:
|
1232
|
+
return []
|
1233
|
+
try:
|
1234
|
+
response = (
|
1235
|
+
self.client.table(self.table_name)
|
1236
|
+
.select("*")
|
1237
|
+
.eq("conversation_id", self.current_conversation_id)
|
1238
|
+
.eq("role", role)
|
1239
|
+
.order("timestamp", desc=False)
|
1240
|
+
.execute()
|
1241
|
+
)
|
1242
|
+
data = self._handle_api_response(
|
1243
|
+
response, f"get_messages_by_role (role: {role})"
|
1244
|
+
)
|
1245
|
+
return [self._format_row_to_dict(row) for row in data]
|
1246
|
+
except Exception as e:
|
1247
|
+
self.logger.error(
|
1248
|
+
f"Error getting messages by role '{role}' from Supabase: {e}"
|
1249
|
+
)
|
1250
|
+
raise SupabaseOperationError(
|
1251
|
+
f"Error getting messages by role '{role}': {e}"
|
1252
|
+
)
|
1253
|
+
|
1254
|
+
def get_conversation_summary(self) -> Dict:
|
1255
|
+
"""Get a summary of the current conversation."""
|
1256
|
+
if self.current_conversation_id is None:
|
1257
|
+
return {"error": "No current conversation."}
|
1258
|
+
|
1259
|
+
# This could be optimized with an RPC call in Supabase for better performance
|
1260
|
+
# Example RPC: CREATE OR REPLACE FUNCTION get_conversation_summary(conv_id TEXT) ...
|
1261
|
+
messages = self.get_messages()
|
1262
|
+
if not messages:
|
1263
|
+
return {
|
1264
|
+
"conversation_id": self.current_conversation_id,
|
1265
|
+
"total_messages": 0,
|
1266
|
+
"unique_roles": 0,
|
1267
|
+
"first_message_time": None,
|
1268
|
+
"last_message_time": None,
|
1269
|
+
"total_tokens": 0,
|
1270
|
+
"roles": {},
|
1271
|
+
}
|
1272
|
+
|
1273
|
+
roles_counts = {}
|
1274
|
+
total_tokens_sum = 0
|
1275
|
+
for msg in messages:
|
1276
|
+
roles_counts[msg["role"]] = (
|
1277
|
+
roles_counts.get(msg["role"], 0) + 1
|
1278
|
+
)
|
1279
|
+
if msg.get("token_count") is not None:
|
1280
|
+
total_tokens_sum += int(msg["token_count"])
|
1281
|
+
|
1282
|
+
return {
|
1283
|
+
"conversation_id": self.current_conversation_id,
|
1284
|
+
"total_messages": len(messages),
|
1285
|
+
"unique_roles": len(roles_counts),
|
1286
|
+
"first_message_time": messages[0].get("timestamp"),
|
1287
|
+
"last_message_time": messages[-1].get("timestamp"),
|
1288
|
+
"total_tokens": total_tokens_sum,
|
1289
|
+
"roles": roles_counts,
|
1290
|
+
}
|
1291
|
+
|
1292
|
+
def get_statistics(self) -> Dict:
|
1293
|
+
"""Get statistics about the current conversation (alias for get_conversation_summary)."""
|
1294
|
+
return self.get_conversation_summary()
|
1295
|
+
|
1296
|
+
def get_conversation_id(self) -> str:
|
1297
|
+
"""Get the current conversation ID."""
|
1298
|
+
return self.current_conversation_id or ""
|
1299
|
+
|
1300
|
+
def delete_current_conversation(self) -> bool:
|
1301
|
+
"""Delete the current conversation. Returns True if successful."""
|
1302
|
+
if self.current_conversation_id:
|
1303
|
+
self.clear() # clear messages for current_conversation_id
|
1304
|
+
self.logger.info(
|
1305
|
+
f"Deleted current conversation: {self.current_conversation_id}"
|
1306
|
+
)
|
1307
|
+
self.current_conversation_id = (
|
1308
|
+
None # No active conversation after deletion
|
1309
|
+
)
|
1310
|
+
return True
|
1311
|
+
self.logger.info("No current conversation to delete.")
|
1312
|
+
return False
|
1313
|
+
|
1314
|
+
def search_messages(self, query: str) -> List[Dict]:
|
1315
|
+
"""Search for messages containing specific text (alias for search)."""
|
1316
|
+
return self.search(keyword=query)
|
1317
|
+
|
1318
|
+
def get_conversation_metadata_dict(self) -> Dict:
|
1319
|
+
"""Get detailed metadata about the conversation."""
|
1320
|
+
# Similar to get_conversation_summary, could be expanded with more DB-side aggregations if needed via RPC.
|
1321
|
+
# For now, returning the summary.
|
1322
|
+
if self.current_conversation_id is None:
|
1323
|
+
return {"error": "No current conversation."}
|
1324
|
+
summary = self.get_conversation_summary()
|
1325
|
+
|
1326
|
+
# Example of additional metadata one might compute client-side or via RPC
|
1327
|
+
# message_type_distribution, average_tokens_per_message, hourly_message_frequency
|
1328
|
+
return {
|
1329
|
+
"conversation_id": self.current_conversation_id,
|
1330
|
+
"basic_stats": summary,
|
1331
|
+
# Placeholder for more detailed stats if implemented
|
1332
|
+
}
|
1333
|
+
|
1334
|
+
def get_conversation_timeline_dict(self) -> Dict[str, List[Dict]]:
|
1335
|
+
"""Get the conversation organized by timestamps (dates as keys)."""
|
1336
|
+
if self.current_conversation_id is None:
|
1337
|
+
return {}
|
1338
|
+
|
1339
|
+
messages = (
|
1340
|
+
self.get_messages()
|
1341
|
+
) # Assumes messages are ordered by timestamp
|
1342
|
+
timeline_dict = {}
|
1343
|
+
for msg in messages:
|
1344
|
+
try:
|
1345
|
+
# Ensure timestamp is a string and valid ISO format
|
1346
|
+
ts_str = msg.get("timestamp")
|
1347
|
+
if isinstance(ts_str, str):
|
1348
|
+
date_key = datetime.datetime.fromisoformat(
|
1349
|
+
ts_str.replace("Z", "+00:00")
|
1350
|
+
).strftime("%Y-%m-%d")
|
1351
|
+
if date_key not in timeline_dict:
|
1352
|
+
timeline_dict[date_key] = []
|
1353
|
+
timeline_dict[date_key].append(msg)
|
1354
|
+
else:
|
1355
|
+
self.logger.warning(
|
1356
|
+
f"Message ID {msg.get('id')} has invalid timestamp format: {ts_str}"
|
1357
|
+
)
|
1358
|
+
except ValueError as e:
|
1359
|
+
self.logger.warning(
|
1360
|
+
f"Could not parse timestamp for message ID {msg.get('id')}: {ts_str}, Error: {e}"
|
1361
|
+
)
|
1362
|
+
|
1363
|
+
return timeline_dict
|
1364
|
+
|
1365
|
+
def get_conversation_by_role_dict(self) -> Dict[str, List[Dict]]:
|
1366
|
+
"""Get the conversation organized by roles."""
|
1367
|
+
if self.current_conversation_id is None:
|
1368
|
+
return {}
|
1369
|
+
|
1370
|
+
messages = self.get_messages()
|
1371
|
+
role_dict = {}
|
1372
|
+
for msg in messages:
|
1373
|
+
role = msg.get("role", "unknown")
|
1374
|
+
if role not in role_dict:
|
1375
|
+
role_dict[role] = []
|
1376
|
+
role_dict[role].append(msg)
|
1377
|
+
return role_dict
|
1378
|
+
|
1379
|
+
def get_conversation_as_dict(self) -> Dict:
|
1380
|
+
"""Get the entire current conversation as a dictionary with messages and metadata."""
|
1381
|
+
if self.current_conversation_id is None:
|
1382
|
+
return {"error": "No current conversation."}
|
1383
|
+
|
1384
|
+
return {
|
1385
|
+
"conversation_id": self.current_conversation_id,
|
1386
|
+
"messages": self.get_messages(),
|
1387
|
+
"metadata": self.get_conversation_summary(), # Using summary as metadata
|
1388
|
+
}
|
1389
|
+
|
1390
|
+
def truncate_memory_with_tokenizer(self):
|
1391
|
+
"""Truncate the conversation history based on token count if a tokenizer is provided. Optimized for better performance."""
|
1392
|
+
if not self.tokenizer or self.current_conversation_id is None:
|
1393
|
+
if self.enable_logging:
|
1394
|
+
self.logger.info(
|
1395
|
+
"Tokenizer not available or no current conversation, skipping truncation."
|
1396
|
+
)
|
1397
|
+
return
|
1398
|
+
|
1399
|
+
try:
|
1400
|
+
# Fetch messages with only necessary fields for efficiency
|
1401
|
+
response = (
|
1402
|
+
self.client.table(self.table_name)
|
1403
|
+
.select("id, content, token_count")
|
1404
|
+
.eq("conversation_id", self.current_conversation_id)
|
1405
|
+
.order("timestamp", desc=False)
|
1406
|
+
.execute()
|
1407
|
+
)
|
1408
|
+
|
1409
|
+
messages = self._handle_api_response(
|
1410
|
+
response, "fetch_messages_for_truncation"
|
1411
|
+
)
|
1412
|
+
if not messages:
|
1413
|
+
return
|
1414
|
+
|
1415
|
+
# Calculate tokens and determine which messages to delete
|
1416
|
+
total_tokens = 0
|
1417
|
+
message_tokens = []
|
1418
|
+
|
1419
|
+
for msg in messages:
|
1420
|
+
token_count = msg.get("token_count")
|
1421
|
+
if token_count is None and self.calculate_token_count:
|
1422
|
+
# Recalculate if missing
|
1423
|
+
content = self._deserialize_content(
|
1424
|
+
msg.get("content", "")
|
1425
|
+
)
|
1426
|
+
token_count = self.tokenizer.count_tokens(
|
1427
|
+
str(content)
|
1428
|
+
)
|
1429
|
+
|
1430
|
+
message_tokens.append(
|
1431
|
+
{"id": msg["id"], "tokens": token_count or 0}
|
1432
|
+
)
|
1433
|
+
total_tokens += token_count or 0
|
1434
|
+
|
1435
|
+
tokens_to_remove = total_tokens - self.context_length
|
1436
|
+
if tokens_to_remove <= 0:
|
1437
|
+
return # No truncation needed
|
1438
|
+
|
1439
|
+
# Collect IDs to delete (oldest first)
|
1440
|
+
ids_to_delete = []
|
1441
|
+
for msg_info in message_tokens:
|
1442
|
+
if tokens_to_remove <= 0:
|
1443
|
+
break
|
1444
|
+
ids_to_delete.append(msg_info["id"])
|
1445
|
+
tokens_to_remove -= msg_info["tokens"]
|
1446
|
+
|
1447
|
+
if not ids_to_delete:
|
1448
|
+
return
|
1449
|
+
|
1450
|
+
# Batch delete for better performance
|
1451
|
+
if len(ids_to_delete) == 1:
|
1452
|
+
# Single delete
|
1453
|
+
response = (
|
1454
|
+
self.client.table(self.table_name)
|
1455
|
+
.delete()
|
1456
|
+
.eq("id", ids_to_delete[0])
|
1457
|
+
.eq(
|
1458
|
+
"conversation_id",
|
1459
|
+
self.current_conversation_id,
|
1460
|
+
)
|
1461
|
+
.execute()
|
1462
|
+
)
|
1463
|
+
else:
|
1464
|
+
# Batch delete using 'in' operator
|
1465
|
+
response = (
|
1466
|
+
self.client.table(self.table_name)
|
1467
|
+
.delete()
|
1468
|
+
.in_("id", ids_to_delete)
|
1469
|
+
.eq(
|
1470
|
+
"conversation_id",
|
1471
|
+
self.current_conversation_id,
|
1472
|
+
)
|
1473
|
+
.execute()
|
1474
|
+
)
|
1475
|
+
|
1476
|
+
self._handle_api_response(
|
1477
|
+
response, "truncate_conversation_batch_delete"
|
1478
|
+
)
|
1479
|
+
|
1480
|
+
if self.enable_logging:
|
1481
|
+
self.logger.info(
|
1482
|
+
f"Truncated conversation {self.current_conversation_id}, removed {len(ids_to_delete)} oldest messages."
|
1483
|
+
)
|
1484
|
+
|
1485
|
+
except Exception as e:
|
1486
|
+
if self.enable_logging:
|
1487
|
+
self.logger.error(
|
1488
|
+
f"Error during memory truncation for conversation {self.current_conversation_id}: {e}"
|
1489
|
+
)
|
1490
|
+
# Don't re-raise, truncation is best-effort
|
1491
|
+
|
1492
|
+
# Methods from duckdb_wrap.py that seem generally useful and can be adapted
|
1493
|
+
def get_visible_messages(
|
1494
|
+
self,
|
1495
|
+
agent: Optional[Callable] = None,
|
1496
|
+
turn: Optional[int] = None,
|
1497
|
+
) -> List[Dict]:
|
1498
|
+
"""
|
1499
|
+
Get visible messages, optionally filtered by agent visibility and turn.
|
1500
|
+
Assumes 'metadata' field can contain 'visible_to' (list of agent names or 'all')
|
1501
|
+
and 'turn' (integer).
|
1502
|
+
"""
|
1503
|
+
if self.current_conversation_id is None:
|
1504
|
+
return []
|
1505
|
+
|
1506
|
+
# Base query
|
1507
|
+
query = (
|
1508
|
+
self.client.table(self.table_name)
|
1509
|
+
.select("*")
|
1510
|
+
.eq("conversation_id", self.current_conversation_id)
|
1511
|
+
.order("timestamp", desc=False)
|
1512
|
+
)
|
1513
|
+
|
1514
|
+
# Execute and then filter in Python, as JSONB querying for array containment or
|
1515
|
+
# numeric comparison within JSON can be complex with supabase-py's fluent API.
|
1516
|
+
# For complex filtering, an RPC function in Supabase would be more efficient.
|
1517
|
+
|
1518
|
+
try:
|
1519
|
+
response = query.execute()
|
1520
|
+
all_messages = self._handle_api_response(
|
1521
|
+
response, "get_visible_messages_fetch_all"
|
1522
|
+
)
|
1523
|
+
except Exception as e:
|
1524
|
+
self.logger.error(
|
1525
|
+
f"Error fetching messages for visibility check: {e}"
|
1526
|
+
)
|
1527
|
+
return []
|
1528
|
+
|
1529
|
+
visible_messages = []
|
1530
|
+
for row_data in all_messages:
|
1531
|
+
msg = self._format_row_to_dict(row_data)
|
1532
|
+
metadata = (
|
1533
|
+
msg.get("metadata")
|
1534
|
+
if isinstance(msg.get("metadata"), dict)
|
1535
|
+
else {}
|
1536
|
+
)
|
1537
|
+
|
1538
|
+
# Turn filtering
|
1539
|
+
if turn is not None:
|
1540
|
+
msg_turn = metadata.get("turn")
|
1541
|
+
if not (
|
1542
|
+
isinstance(msg_turn, int) and msg_turn < turn
|
1543
|
+
):
|
1544
|
+
continue # Skip if turn condition not met
|
1545
|
+
|
1546
|
+
# Agent visibility filtering
|
1547
|
+
if agent is not None:
|
1548
|
+
visible_to = metadata.get("visible_to")
|
1549
|
+
agent_name_attr = getattr(
|
1550
|
+
agent, "agent_name", None
|
1551
|
+
) # Safely get agent_name
|
1552
|
+
if (
|
1553
|
+
agent_name_attr is None
|
1554
|
+
): # If agent has no name, assume it can't see restricted msgs
|
1555
|
+
if visible_to is not None and visible_to != "all":
|
1556
|
+
continue
|
1557
|
+
elif (
|
1558
|
+
isinstance(visible_to, list)
|
1559
|
+
and agent_name_attr not in visible_to
|
1560
|
+
):
|
1561
|
+
continue # Skip if agent not in visible_to list
|
1562
|
+
elif (
|
1563
|
+
isinstance(visible_to, str)
|
1564
|
+
and visible_to != "all"
|
1565
|
+
):
|
1566
|
+
# If visible_to is a string but not "all", and doesn't match agent_name
|
1567
|
+
if visible_to != agent_name_attr:
|
1568
|
+
continue
|
1569
|
+
|
1570
|
+
visible_messages.append(msg)
|
1571
|
+
return visible_messages
|
1572
|
+
|
1573
|
+
def return_messages_as_list(self) -> List[str]:
|
1574
|
+
"""Return the conversation messages as a list of formatted strings 'role: content'."""
|
1575
|
+
messages_dict = self.get_messages()
|
1576
|
+
return [
|
1577
|
+
f"{msg.get('role', 'unknown')}: {self._serialize_content(msg.get('content', ''))}"
|
1578
|
+
for msg in messages_dict
|
1579
|
+
]
|
1580
|
+
|
1581
|
+
def return_messages_as_dictionary(self) -> List[Dict]:
|
1582
|
+
"""Return the conversation messages as a list of dictionaries [{role: R, content: C}]."""
|
1583
|
+
messages_dict = self.get_messages()
|
1584
|
+
return [
|
1585
|
+
{
|
1586
|
+
"role": msg.get("role"),
|
1587
|
+
"content": msg.get("content"),
|
1588
|
+
} # Content already deserialized by _format_row_to_dict
|
1589
|
+
for msg in messages_dict
|
1590
|
+
]
|
1591
|
+
|
1592
|
+
def add_tool_output_to_agent(
|
1593
|
+
self, role: str, tool_output: dict
|
1594
|
+
): # role is usually "tool"
|
1595
|
+
"""Add a tool output to the conversation history."""
|
1596
|
+
# Assuming tool_output is a dict that should be stored as content
|
1597
|
+
self.add(
|
1598
|
+
role=role,
|
1599
|
+
content=tool_output,
|
1600
|
+
message_type=MessageType.TOOL,
|
1601
|
+
)
|
1602
|
+
|
1603
|
+
def get_final_message(self) -> Optional[str]:
|
1604
|
+
"""Return the final message from the conversation history as 'role: content' string."""
|
1605
|
+
last_msg = self.get_last_message()
|
1606
|
+
if not last_msg:
|
1607
|
+
return None
|
1608
|
+
content_display = last_msg["content"]
|
1609
|
+
if isinstance(content_display, (dict, list)):
|
1610
|
+
content_display = json.dumps(
|
1611
|
+
content_display, cls=DateTimeEncoder
|
1612
|
+
)
|
1613
|
+
return f"{last_msg.get('role', 'unknown')}: {content_display}"
|
1614
|
+
|
1615
|
+
def get_final_message_content(
|
1616
|
+
self,
|
1617
|
+
) -> Union[str, dict, list, None]:
|
1618
|
+
"""Return the content of the final message from the conversation history."""
|
1619
|
+
last_msg = self.get_last_message()
|
1620
|
+
return last_msg.get("content") if last_msg else None
|
1621
|
+
|
1622
|
+
def return_all_except_first(self) -> List[Dict]:
|
1623
|
+
"""Return all messages except the first one."""
|
1624
|
+
# The limit=-1, offset=2 from duckdb_wrap is specific to its ID generation.
|
1625
|
+
# For Supabase, we fetch all and skip the first one in Python.
|
1626
|
+
all_messages = self.get_messages()
|
1627
|
+
return all_messages[1:] if len(all_messages) > 1 else []
|
1628
|
+
|
1629
|
+
def return_all_except_first_string(self) -> str:
|
1630
|
+
"""Return all messages except the first one as a concatenated string."""
|
1631
|
+
messages_to_format = self.return_all_except_first()
|
1632
|
+
conv_str = []
|
1633
|
+
for msg in messages_to_format:
|
1634
|
+
ts_prefix = (
|
1635
|
+
f"[{msg['timestamp']}] "
|
1636
|
+
if msg.get("timestamp") and self.time_enabled
|
1637
|
+
else ""
|
1638
|
+
)
|
1639
|
+
content_display = msg["content"]
|
1640
|
+
if isinstance(content_display, (dict, list)):
|
1641
|
+
content_display = json.dumps(
|
1642
|
+
content_display, indent=2, cls=DateTimeEncoder
|
1643
|
+
)
|
1644
|
+
conv_str.append(
|
1645
|
+
f"{ts_prefix}{msg['role']}: {content_display}"
|
1646
|
+
)
|
1647
|
+
return "\n".join(conv_str)
|
1648
|
+
|
1649
|
+
def update_message(
|
1650
|
+
self,
|
1651
|
+
message_id: int,
|
1652
|
+
content: Union[str, dict, list],
|
1653
|
+
metadata: Optional[Dict] = None,
|
1654
|
+
) -> bool:
|
1655
|
+
"""Update an existing message. Matches BaseCommunication.update_message signature exactly."""
|
1656
|
+
# Use the flexible internal method
|
1657
|
+
return self._update_flexible(
|
1658
|
+
index=message_id, content=content, metadata=metadata
|
1659
|
+
)
|