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.
Files changed (58) hide show
  1. swarms/agents/ape_agent.py +5 -22
  2. swarms/agents/consistency_agent.py +1 -1
  3. swarms/agents/i_agent.py +1 -1
  4. swarms/agents/reasoning_agents.py +99 -3
  5. swarms/agents/reasoning_duo.py +1 -1
  6. swarms/cli/main.py +1 -1
  7. swarms/communication/__init__.py +1 -0
  8. swarms/communication/duckdb_wrap.py +32 -2
  9. swarms/communication/pulsar_struct.py +45 -19
  10. swarms/communication/redis_wrap.py +56 -11
  11. swarms/communication/supabase_wrap.py +1659 -0
  12. swarms/prompts/prompt.py +0 -3
  13. swarms/schemas/agent_completion_response.py +71 -0
  14. swarms/schemas/agent_rag_schema.py +7 -0
  15. swarms/schemas/conversation_schema.py +9 -0
  16. swarms/schemas/llm_agent_schema.py +99 -81
  17. swarms/schemas/swarms_api_schemas.py +164 -0
  18. swarms/structs/__init__.py +14 -11
  19. swarms/structs/agent.py +219 -199
  20. swarms/structs/agent_rag_handler.py +685 -0
  21. swarms/structs/base_swarm.py +2 -1
  22. swarms/structs/conversation.py +608 -87
  23. swarms/structs/csv_to_agent.py +153 -100
  24. swarms/structs/deep_research_swarm.py +197 -193
  25. swarms/structs/dynamic_conversational_swarm.py +18 -7
  26. swarms/structs/hiearchical_swarm.py +1 -1
  27. swarms/structs/hybrid_hiearchical_peer_swarm.py +2 -18
  28. swarms/structs/image_batch_processor.py +261 -0
  29. swarms/structs/interactive_groupchat.py +356 -0
  30. swarms/structs/ma_blocks.py +75 -0
  31. swarms/structs/majority_voting.py +1 -1
  32. swarms/structs/mixture_of_agents.py +1 -1
  33. swarms/structs/multi_agent_router.py +3 -2
  34. swarms/structs/rearrange.py +3 -3
  35. swarms/structs/sequential_workflow.py +3 -3
  36. swarms/structs/swarm_matcher.py +500 -411
  37. swarms/structs/swarm_router.py +15 -97
  38. swarms/structs/swarming_architectures.py +1 -1
  39. swarms/tools/mcp_client_call.py +3 -0
  40. swarms/utils/__init__.py +10 -2
  41. swarms/utils/check_all_model_max_tokens.py +43 -0
  42. swarms/utils/generate_keys.py +0 -27
  43. swarms/utils/history_output_formatter.py +5 -20
  44. swarms/utils/litellm_wrapper.py +208 -60
  45. swarms/utils/output_types.py +24 -0
  46. swarms/utils/vllm_wrapper.py +5 -6
  47. swarms/utils/xml_utils.py +37 -2
  48. {swarms-7.8.4.dist-info → swarms-7.8.7.dist-info}/METADATA +31 -55
  49. {swarms-7.8.4.dist-info → swarms-7.8.7.dist-info}/RECORD +53 -48
  50. swarms/structs/multi_agent_collab.py +0 -242
  51. swarms/structs/output_types.py +0 -6
  52. swarms/utils/markdown_message.py +0 -21
  53. swarms/utils/visualizer.py +0 -510
  54. swarms/utils/wrapper_clusterop.py +0 -127
  55. /swarms/{tools → schemas}/tool_schema_base_model.py +0 -0
  56. {swarms-7.8.4.dist-info → swarms-7.8.7.dist-info}/LICENSE +0 -0
  57. {swarms-7.8.4.dist-info → swarms-7.8.7.dist-info}/WHEEL +0 -0
  58. {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
+ )