diff --git a/docs/src/content/docs/storage/dynamodb.mdx b/docs/src/content/docs/storage/dynamodb.mdx index b3917bed..426a9677 100644 --- a/docs/src/content/docs/storage/dynamodb.mdx +++ b/docs/src/content/docs/storage/dynamodb.mdx @@ -17,6 +17,14 @@ DynamoDB storage provides a scalable and persistent solution for storing convers - When long-term persistence of conversation history is required - For applications that need to scale horizontally +## Python Package + +If you haven't already installed the AWS-related dependencies, make sure to install them: + +```bash +pip install "multi-agent-orchestrator[aws]" +``` + ## Implementation To use DynamoDB storage in your Multi-Agent Orchestrator: diff --git a/docs/src/content/docs/storage/overview.md b/docs/src/content/docs/storage/overview.md index 79211746..78c46264 100644 --- a/docs/src/content/docs/storage/overview.md +++ b/docs/src/content/docs/storage/overview.md @@ -21,19 +21,25 @@ The Multi-Agent Orchestrator System offers flexible storage options for maintain - Provides persistent storage for production environments. - Allows for scalable and durable conversation history storage. -3. **Custom Storage Solutions**: +3. **SQL Storage**: + - Offers persistent storage using SQLite or Turso databases. + - When you need local-first development with remote deployment options + +4. **Custom Storage Solutions**: - The system allows for implementation of custom storage options to meet specific needs. ## Choosing the Right Storage Option - Use In-Memory Storage for development, testing, or when persistence between application restarts is not necessary. - Choose DynamoDB Storage for production environments where conversation history needs to be preserved long-term or across multiple instances of your application. +- Consider SQL Storage for a balance between simplicity and scalability, supporting both local and remote databases. - Implement a custom storage solution if you have specific requirements not met by the provided options. ## Next Steps - Learn more about [In-Memory Storage](/multi-agent-orchestrator/storage/in-memory) - Explore [DynamoDB Storage](/multi-agent-orchestrator/storage/dynamodb) for persistent storage +- Explore [SQL Storage](/multi-agent-orchestrator/storage/sql) for persistent storage using SQLite or Turso. - Discover how to [implement custom storage solutions](/multi-agent-orchestrator/storage/custom) By leveraging these storage options, you can ensure that your Multi-Agent Orchestrator System maintains the necessary context for coherent and effective conversations across various use cases and deployment scenarios. \ No newline at end of file diff --git a/docs/src/content/docs/storage/sql.mdx b/docs/src/content/docs/storage/sql.mdx new file mode 100644 index 00000000..68e316fe --- /dev/null +++ b/docs/src/content/docs/storage/sql.mdx @@ -0,0 +1,144 @@ +--- +title: SQL Storage +description: Using SQL databases (SQLite/Turso) for persistent conversation storage in the Multi-Agent Orchestrator System +--- + +SQL storage provides a flexible and reliable solution for storing conversation history in the Multi-Agent Orchestrator System. This implementation supports both local SQLite databases and remote Turso databases, making it suitable for various deployment scenarios from development to production. + +## Features + +- Persistent storage across application restarts +- Support for both local and remote databases +- Built-in connection pooling and retry mechanisms +- Compatible with edge and serverless deployments +- Transaction support for data consistency +- Efficient indexing for quick data retrieval + +## When to Use SQL Storage + +- When you need a balance between simplicity and scalability +- For applications requiring persistent storage without complex infrastructure +- In both development and production environments +- When working with edge or serverless deployments +- When you need local-first development with remote deployment options + +## Python Package Installation + +To use SQL storage in your Python application, make sure to install them: + +```bash +pip install "multi-agent-orchestrator[sql]" +``` + +This will install the `libsql-client` package required for SQL storage functionality. + +## Implementation + +To use SQL storage in your Multi-Agent Orchestrator: + +import { Tabs, TabItem } from '@astrojs/starlight/components'; + + + + ```typescript + import { SqlChatStorage, MultiAgentOrchestrator } from 'multi-agent-orchestrator'; + + // For local SQLite database + const localStorage = new SqlChatStorage('file:local.db'); + await localStorage.waitForInitialization(); + + // For remote database + const remoteStorage = new SqlChatStorage( + 'libsql://your-database-url.example.com', + 'your-auth-token' + ); + await remoteStorage.waitForInitialization(); + + const orchestrator = new MultiAgentOrchestrator({ + storage: localStorage // or remoteStorage + }); + + + // Close the database connections when done + await localStorage.close(); + await remoteStorage.close(); + ``` + + + ```python + from multi_agent_orchestrator.storage import SqlChatStorage + from multi_agent_orchestrator.orchestrator import MultiAgentOrchestrator + + # For local SQLite database + local_storage = SqlChatStorage('file:local.db') + + # For remote Turso database + remote_storage = SqlChatStorage( + url='libsql://your-database-url.turso.io', + auth_token='your-auth-token' + ) + + orchestrator = MultiAgentOrchestrator(storage=local_storage) # or remote_storage + ``` + + + +## Configuration + +### Local DB +For local development, simply provide a file URL: +```typescript +const storage = new SqlChatStorage('file:local.db'); +``` + +### Remote DB +For production with Turso: +1. Create a Turso database through their platform +2. Obtain your database URL and authentication token +3. Configure your storage: +```typescript +const storage = new SqlChatStorage( + 'libsql://your-database-url.com', + 'your-auth-token' +); +``` + +## Database Schema + +The SQL storage implementation uses the following schema: + +```sql +CREATE TABLE conversations ( + user_id TEXT NOT NULL, + session_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + message_index INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + PRIMARY KEY (user_id, session_id, agent_id, message_index) +); + +CREATE INDEX idx_conversations_lookup +ON conversations(user_id, session_id, agent_id); +``` + +## Considerations + +- Automatic table and index creation on initialization +- Built-in transaction support for data consistency +- Efficient query performance through proper indexing +- Support for message history size limits +- Automatic JSON serialization/deserialization of message content + +## Best Practices + +- Use a single instance of SqlChatStorage throughout your application +- Regularly backup your database +- Implement data cleanup strategies for old conversations +- Monitor database size and performance +- Keep your authentication tokens secure +- Implement proper access controls at the application level +- Close the database connections when done + +SQL storage provides a robust and flexible solution for managing conversation history in the Multi-Agent Orchestrator System. It offers a good balance between simplicity and features, making it suitable for both development and production environments. \ No newline at end of file diff --git a/python/setup.cfg b/python/setup.cfg index 8909a797..338d2c48 100644 --- a/python/setup.cfg +++ b/python/setup.cfg @@ -27,10 +27,13 @@ anthropic = anthropic>=0.40.0 openai = openai>=1.55.3 +sql = + libsql-client>=0.3.1 all = anthropic==0.40.0 openai==1.55.3 boto3==1.35.0 + libsql-client==0.3.1 [options.packages.find] where = src diff --git a/python/src/multi_agent_orchestrator/storage/__init__.py b/python/src/multi_agent_orchestrator/storage/__init__.py index 51d3279d..8ddaaa78 100644 --- a/python/src/multi_agent_orchestrator/storage/__init__.py +++ b/python/src/multi_agent_orchestrator/storage/__init__.py @@ -5,6 +5,7 @@ from .in_memory_chat_storage import InMemoryChatStorage _AWS_AVAILABLE = False +_SQL_AVAILABLE = False try: from .dynamodb_chat_storage import DynamoDbChatStorage @@ -12,6 +13,12 @@ except ImportError: _AWS_AVAILABLE = False +try: + from .sql_chat_storage import SqlChatStorage + _SQL_AVAILABLE = True +except ImportError: + _SQL_AVAILABLE = False + __all__ = [ 'ChatStorage', 'InMemoryChatStorage', @@ -20,4 +27,9 @@ if _AWS_AVAILABLE: __all__.extend([ 'DynamoDbChatStorage' + ]) + +if _SQL_AVAILABLE: + __all__.extend([ + 'SqlChatStorage' ]) \ No newline at end of file diff --git a/python/src/multi_agent_orchestrator/storage/sql_chat_storage.py b/python/src/multi_agent_orchestrator/storage/sql_chat_storage.py new file mode 100644 index 00000000..b8274944 --- /dev/null +++ b/python/src/multi_agent_orchestrator/storage/sql_chat_storage.py @@ -0,0 +1,202 @@ +from typing import List, Dict, Optional, Union +import time +import json +from libsql_client import Client, create_client +from multi_agent_orchestrator.storage import ChatStorage +from multi_agent_orchestrator.types import ConversationMessage, ParticipantRole, TimestampedMessage +from multi_agent_orchestrator.utils import Logger + +class SqlChatStorage(ChatStorage): + """SQL-based chat storage implementation supporting both local SQLite and remote Turso databases.""" + + def __init__( + self, + url: str, + auth_token: Optional[str] = None + ): + """Initialize SQL storage. + + Args: + url: Database URL (e.g., 'file:local.db' or 'libsql://your-db-url.com') + auth_token: Authentication token for remote databases (optional) + """ + super().__init__() + self.client = create_client( + url=url, + auth_token=auth_token + ) + self._initialize_database() + + def _initialize_database(self) -> None: + """Create necessary tables and indexes if they don't exist.""" + try: + # Create conversations table + self.client.execute(""" + CREATE TABLE IF NOT EXISTS conversations ( + user_id TEXT NOT NULL, + session_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + message_index INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + PRIMARY KEY (user_id, session_id, agent_id, message_index) + ) + """) + + # Create index for faster queries + self.client.execute(""" + CREATE INDEX IF NOT EXISTS idx_conversations_lookup + ON conversations(user_id, session_id, agent_id) + """) + except Exception as error: + Logger.error(f"Error initializing database: {str(error)}") + raise error + + async def save_chat_message( + self, + user_id: str, + session_id: str, + agent_id: str, + new_message: ConversationMessage, + max_history_size: Optional[int] = None + ) -> List[ConversationMessage]: + """Save a new chat message.""" + try: + # Fetch existing conversation + existing_conversation = await self.fetch_chat(user_id, session_id, agent_id) + + if self.is_consecutive_message(existing_conversation, new_message): + Logger.debug(f"> Consecutive {new_message.role} message detected for agent {agent_id}. Not saving.") + return existing_conversation + + # Get next message index + result = self.client.execute(""" + SELECT COALESCE(MAX(message_index) + 1, 0) as next_index + FROM conversations + WHERE user_id = ? AND session_id = ? AND agent_id = ? + """, [user_id, session_id, agent_id]) + + next_index = result.rows[0]['next_index'] + timestamp = int(time.time() * 1000) + content = json.dumps(new_message.content) + + # Begin transaction + with self.client.transaction(): + # Insert new message + self.client.execute(""" + INSERT INTO conversations ( + user_id, session_id, agent_id, message_index, + role, content, timestamp + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, [ + user_id, session_id, agent_id, next_index, + new_message.role, content, timestamp + ]) + + # Clean up old messages if max_history_size is set + if max_history_size is not None: + self.client.execute(""" + DELETE FROM conversations + WHERE user_id = ? + AND session_id = ? + AND agent_id = ? + AND message_index <= ( + SELECT MAX(message_index) - ? + FROM conversations + WHERE user_id = ? + AND session_id = ? + AND agent_id = ? + ) + """, [ + user_id, session_id, agent_id, + max_history_size, + user_id, session_id, agent_id + ]) + + # Return updated conversation + return await self.fetch_chat(user_id, session_id, agent_id, max_history_size) + except Exception as error: + Logger.error(f"Error saving message: {str(error)}") + raise error + + async def fetch_chat( + self, + user_id: str, + session_id: str, + agent_id: str, + max_history_size: Optional[int] = None + ) -> List[ConversationMessage]: + """Fetch chat messages.""" + try: + query = """ + SELECT role, content, timestamp + FROM conversations + WHERE user_id = ? AND session_id = ? AND agent_id = ? + ORDER BY message_index {} + """.format('DESC LIMIT ?' if max_history_size else 'ASC') + + params = [user_id, session_id, agent_id] + if max_history_size: + params.append(max_history_size) + + result = self.client.execute(query, params) + messages = result.rows + + # If we used LIMIT, reverse to maintain chronological order + if max_history_size: + messages.reverse() + + return [ + ConversationMessage( + role=msg['role'], + content=json.loads(msg['content']) + ) for msg in messages + ] + except Exception as error: + Logger.error(f"Error fetching chat: {str(error)}") + raise error + + async def fetch_all_chats( + self, + user_id: str, + session_id: str + ) -> List[ConversationMessage]: + """Fetch all chat messages for a user and session.""" + try: + result = self.client.execute(""" + SELECT role, content, timestamp, agent_id + FROM conversations + WHERE user_id = ? AND session_id = ? + ORDER BY timestamp ASC + """, [user_id, session_id]) + + return [ + ConversationMessage( + role=msg['role'], + content=self._format_content( + msg['role'], + json.loads(msg['content']), + msg['agent_id'] + ) + ) for msg in result.rows + ] + except Exception as error: + Logger.error(f"Error fetching all chats: {str(error)}") + raise error + + def _format_content( + self, + role: str, + content: Union[List, str], + agent_id: str + ) -> List[Dict[str, str]]: + """Format message content with agent ID for assistant messages.""" + if role == ParticipantRole.ASSISTANT.value: + text = content[0]['text'] if isinstance(content, list) else content + return [{'text': f"[{agent_id}] {text}"}] + return content if isinstance(content, list) else [{'text': content}] + + def close(self) -> None: + """Close the database connection.""" + self.client.close() \ No newline at end of file diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 494c1b35..5060785b 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-agent-orchestrator", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-agent-orchestrator", - "version": "0.1.1", + "version": "0.1.2", "license": "Apache-2.0", "dependencies": { "@anthropic-ai/sdk": "^0.24.3", @@ -18,8 +18,8 @@ "@aws-sdk/client-lex-runtime-v2": "^3.621.0", "@aws-sdk/lib-dynamodb": "^3.621.0", "@aws-sdk/util-dynamodb": "^3.621.0", + "@libsql/client": "^0.14.0", "axios": "^1.7.2", - "chai": "^5.1.2", "eslint-config-prettier": "^9.1.0", "natural": "^7.0.7", "openai": "^4.52.7", @@ -2947,6 +2947,168 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@libsql/client": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.14.0.tgz", + "integrity": "sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q==", + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.14.0", + "@libsql/hrana-client": "^0.7.0", + "js-base64": "^3.7.5", + "libsql": "^0.4.4", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.14.0.tgz", + "integrity": "sha512-nhbuXf7GP3PSZgdCY2Ecj8vz187ptHlZQ0VRc751oB2C1W8jQUXKKklvt7t1LJiUTQBVJuadF628eUk+3cRi4Q==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.4.7.tgz", + "integrity": "sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.4.7.tgz", + "integrity": "sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.7.0.tgz", + "integrity": "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==", + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-fetch": "^0.3.1", + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5", + "node-fetch": "^3.3.2" + } + }, + "node_modules/@libsql/hrana-client/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@libsql/isomorphic-fetch": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-fetch/-/isomorphic-fetch-0.3.1.tgz", + "integrity": "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.4.7.tgz", + "integrity": "sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.4.7.tgz", + "integrity": "sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.4.7.tgz", + "integrity": "sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.4.7.tgz", + "integrity": "sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.4.7.tgz", + "integrity": "sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@mongodb-js/saslprep": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", @@ -2956,6 +3118,12 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3859,6 +4027,15 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/ws": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", @@ -4271,14 +4448,6 @@ "node": ">=8" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "engines": { - "node": ">=12" - } - }, "node_modules/async": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", @@ -4598,21 +4767,6 @@ } ] }, - "node_modules/chai": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", - "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4637,14 +4791,6 @@ "node": ">=10" } }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "engines": { - "node": ">= 16" - } - }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -4790,6 +4936,15 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", @@ -4825,14 +4980,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5412,6 +5559,29 @@ "bser": "2.1.1" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -5553,6 +5723,18 @@ "node": ">= 14" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -6705,6 +6887,12 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-base64": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", + "integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6821,6 +7009,44 @@ "node": ">= 0.8.0" } }, + "node_modules/libsql": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.4.7.tgz", + "integrity": "sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==", + "cpu": [ + "x64", + "arm64", + "wasm32" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.4.7", + "@libsql/darwin-x64": "0.4.7", + "@libsql/linux-arm64-gnu": "0.4.7", + "@libsql/linux-arm64-musl": "0.4.7", + "@libsql/linux-x64-gnu": "0.4.7", + "@libsql/linux-x64-musl": "0.4.7", + "@libsql/win32-x64-msvc": "0.4.7" + } + }, + "node_modules/libsql/node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6861,11 +7087,6 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, - "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7498,14 +7719,6 @@ "node": ">=8" } }, - "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/pg": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", @@ -7730,6 +7943,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -8682,6 +8901,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/typescript/package.json b/typescript/package.json index 0b57005d..91c16c96 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -36,6 +36,7 @@ "@aws-sdk/client-lex-runtime-v2": "^3.621.0", "@aws-sdk/lib-dynamodb": "^3.621.0", "@aws-sdk/util-dynamodb": "^3.621.0", + "@libsql/client": "^0.14.0", "axios": "^1.7.2", "eslint-config-prettier": "^9.1.0", "natural": "^7.0.7", diff --git a/typescript/src/storage/sqlChatStorage.ts b/typescript/src/storage/sqlChatStorage.ts new file mode 100644 index 00000000..59c3c93f --- /dev/null +++ b/typescript/src/storage/sqlChatStorage.ts @@ -0,0 +1,205 @@ +import { Client, createClient } from '@libsql/client'; +import { ConversationMessage, ParticipantRole } from "../types"; +import { Logger } from "../utils/logger"; +import { ChatStorage } from "./chatStorage"; + +export class SqlChatStorage extends ChatStorage { + private client: Client; + private initialized: Promise; + + constructor(url: string, authToken?: string) { + super(); + this.client = createClient({ + url, + authToken, + }); + this.initialized = this.initializeDatabase(); + } + + private async initializeDatabase() { + try { + // Create conversations table if it doesn't exist + await this.client.execute(/*sql*/` + CREATE TABLE IF NOT EXISTS conversations ( + user_id TEXT NOT NULL, + session_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + message_index INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + PRIMARY KEY (user_id, session_id, agent_id, message_index) + ); + `); + + // Create index for faster queries + await this.client.execute(/*sql*/` + CREATE INDEX IF NOT EXISTS idx_conversations_lookup + ON conversations(user_id, session_id, agent_id); + `); + } catch (error) { + Logger.logger.error("Error initializing database:", error); + throw new Error("Database initialization error"); + } + } + + async saveChatMessage( + userId: string, + sessionId: string, + agentId: string, + newMessage: ConversationMessage, + maxHistorySize?: number + ): Promise { + try { + // Fetch existing conversation + const existingConversation = await this.fetchChat(userId, sessionId, agentId); + + if (super.isConsecutiveMessage(existingConversation, newMessage)) { + Logger.logger.log(`> Consecutive ${newMessage.role} message detected for agent ${agentId}. Not saving.`); + return existingConversation; + } + + // Begin transaction + await this.client.transaction().then(async (tx) => { + // Get the next message index + const nextIndexResult = await tx.execute({ + sql: /*sql*/` + SELECT COALESCE(MAX(message_index) + 1, 0) as next_index + FROM conversations + WHERE user_id = ? AND session_id = ? AND agent_id = ? + `, + args: [userId, sessionId, agentId] + }); + + const nextIndex = nextIndexResult.rows[0].next_index as number; + const timestamp = Date.now(); + const content = Array.isArray(newMessage.content) + ? JSON.stringify(newMessage.content) + : JSON.stringify([{ text: newMessage.content }]); + + // Insert new message + await tx.execute({ + sql: /*sql*/` + INSERT INTO conversations ( + user_id, session_id, agent_id, message_index, + role, content, timestamp + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + args: [ + userId, + sessionId, + agentId, + nextIndex, + newMessage.role, + content, + timestamp + ] + }); + + // If maxHistorySize is set, cleanup old messages + if (maxHistorySize !== undefined) { + await tx.execute({ + sql: /*sql*/` + DELETE FROM conversations + WHERE user_id = ? + AND session_id = ? + AND agent_id = ? + AND message_index <= ( + SELECT MAX(message_index) - ? + FROM conversations + WHERE user_id = ? + AND session_id = ? + AND agent_id = ? + ) + `, + args: [ + userId, sessionId, agentId, + maxHistorySize, + userId, sessionId, agentId + ] + }); + } + }); + + // Return updated conversation + return this.fetchChat(userId, sessionId, agentId, maxHistorySize); + } catch (error) { + Logger.logger.error("Error saving message:", error); + throw error; + } + } + + async fetchChat( + userId: string, + sessionId: string, + agentId: string, + maxHistorySize?: number + ): Promise { + try { + const sql = /*sql*/` + SELECT role, content, timestamp + FROM conversations + WHERE user_id = ? AND session_id = ? AND agent_id = ? + ORDER BY message_index ${maxHistorySize ? 'DESC LIMIT ?' : 'ASC'} + `; + + const args = maxHistorySize + ? [userId, sessionId, agentId, maxHistorySize] + : [userId, sessionId, agentId]; + + const result = await this.client.execute({ sql, args }); + const messages = result.rows; + + // If we used LIMIT, we need to reverse the results to maintain chronological order + if (maxHistorySize) messages.reverse(); + + return messages.map(msg => ({ + role: msg.role as ParticipantRole, + content: JSON.parse(msg.content as string), + })); + } catch (error) { + Logger.logger.error("Error fetching chat:", error); + throw error; + } + } + + async fetchAllChats( + userId: string, + sessionId: string + ): Promise { + try { + const result = await this.client.execute({ + sql: /*sql*/` + SELECT role, content, timestamp, agent_id + FROM conversations + WHERE user_id = ? AND session_id = ? + ORDER BY timestamp ASC + `, + args: [userId, sessionId] + }); + + const messages = result.rows; + + return messages.map(msg => ({ + role: msg.role as ParticipantRole, + content: msg.role === ParticipantRole.ASSISTANT + ? [{ text: `[${msg.agent_id}] ${JSON.parse(msg.content as string)[0]?.text || ''}` }] + : JSON.parse(msg.content as string) + })); + } catch (error) { + Logger.logger.error("Error fetching all chats:", error); + throw error; + } + } + + async waitForInitialization() { + if (this.client.closed) { + throw new Error("Database connection closed"); + } + await this.initialized; + } + + close() { + this.client.close(); + } +}