diff --git a/.gitignore b/.gitignore index 6f398cbf9e..f03bf9c15e 100644 --- a/.gitignore +++ b/.gitignore @@ -92,6 +92,29 @@ uv.lock docs/_build/ site/ +# Claude Code specific +CLAUDE.md.bak +*.claude +.claude/ +claude_context/ +claude_logs/ + +# SQLite databases (development only) +*.db +*.sqlite +*.sqlite3 +memory.db +agent_memory.db + +# Python compatibility testing files (development only) +check_py39_compatibility.py +test_runtime_compatibility.py +test_py39_docker.py +test_simple_py39.py +test_core_py39.py +test_sqlite_docker.py +test_real_sqlite_docker.py + # Misc .DS_Store Thumbs.db diff --git a/pyproject.toml b/pyproject.toml index 224f201cd2..7ce91be140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ # go/keep-sorted start "PyYAML>=6.0.2, <7.0.0", # For APIHubToolset. "absolufy-imports>=0.3.1, <1.0.0", # For Agent Engine deployment. + "aiosqlite>=0.20.0, <1.0.0", # For SqliteMemoryService "anyio>=4.9.0, <5.0.0;python_version>='3.10'", # For MCP Session Manager "authlib>=1.5.1, <2.0.0", # For RestAPI Tool "click>=8.1.8, <9.0.0", # For CLI tools diff --git a/src/google/adk/memory/__init__.py b/src/google/adk/memory/__init__.py index 915d7e5178..b79f79d7a9 100644 --- a/src/google/adk/memory/__init__.py +++ b/src/google/adk/memory/__init__.py @@ -15,6 +15,7 @@ from .base_memory_service import BaseMemoryService from .in_memory_memory_service import InMemoryMemoryService +from .sqlite_memory_service import SqliteMemoryService from .vertex_ai_memory_bank_service import VertexAiMemoryBankService logger = logging.getLogger('google_adk.' + __name__) @@ -22,6 +23,7 @@ __all__ = [ 'BaseMemoryService', 'InMemoryMemoryService', + 'SqliteMemoryService', 'VertexAiMemoryBankService', ] diff --git a/src/google/adk/memory/sqlite_memory_service.py b/src/google/adk/memory/sqlite_memory_service.py new file mode 100644 index 0000000000..4aae1c82f0 --- /dev/null +++ b/src/google/adk/memory/sqlite_memory_service.py @@ -0,0 +1,361 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from pathlib import Path +from typing import Any +from typing import Dict +from typing import TYPE_CHECKING + +import aiosqlite +from google.genai import types +from typing_extensions import override + +from . import _utils +from .base_memory_service import BaseMemoryService +from .base_memory_service import SearchMemoryResponse +from .memory_entry import MemoryEntry + +if TYPE_CHECKING: + from ..sessions.session import Session + +logger = logging.getLogger('google_adk.' + __name__) + + +def _prepare_fts_query(query: str) -> str: + """Prepares a query for FTS5 by escaping special characters. + + FTS5 has special characters like quotes that need to be escaped. + We also wrap each word with quotes for exact word matching. + """ + # Remove special FTS5 characters and split into words + words = re.findall(r'\w+', query.lower()) + if not words: + return '' + # Join words with OR to match any of the words + return ' OR '.join(f'"{word}"' for word in words) + + +class SqliteMemoryService(BaseMemoryService): + """An async SQLite-based memory service for persistent storage with FTS5 search. + + This implementation provides persistent storage of memory entries in a SQLite + database using aiosqlite for async operations with SQLite FTS5 (Full-Text Search) + for efficient and scalable search functionality. + + This service is suitable for development and production use where persistent + memory with fast text search is required. + """ + + def __init__(self, db_path: str = 'memory.db'): + """Initializes a SqliteMemoryService. + + Args: + db_path: Path to the SQLite database file. Defaults to 'memory.db' + in the current directory. + """ + self._db_path = Path(db_path) + self._initialized = False + self._init_lock = None + + async def _ensure_initialized(self): + """Ensures the database is initialized exactly once using lazy initialization.""" + if self._initialized: + return + + # Lazy creation of the lock to avoid event loop issues in Python 3.9 + if self._init_lock is None: + self._init_lock = asyncio.Lock() + + async with self._init_lock: + # Double-check after acquiring lock + if self._initialized: + return + + try: + await self._init_db() + self._initialized = True + except aiosqlite.Error as e: + logger.error(f'Failed to initialize database: {e}') + # Don't mark as initialized if init failed + raise + + async def _init_db(self): + """Initializes the SQLite database with required tables and FTS5 virtual table.""" + # Create directory if it doesn't exist + self._db_path.parent.mkdir(parents=True, exist_ok=True) + + async with aiosqlite.connect(self._db_path) as db: + # Create memory_entries table + await db.execute(""" + CREATE TABLE IF NOT EXISTS memory_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app_name TEXT NOT NULL, + user_id TEXT NOT NULL, + session_id TEXT NOT NULL, + author TEXT, + timestamp REAL NOT NULL, + content_json TEXT NOT NULL, + text_content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(app_name, user_id, session_id, timestamp, author) + ) + """) + + # Create FTS5 virtual table for efficient full-text search + await db.execute(""" + CREATE VIRTUAL TABLE IF NOT EXISTS memory_entries_fts USING fts5( + app_name, + user_id, + text_content, + content='memory_entries', + content_rowid='id' + ) + """) + + # Create triggers to keep FTS5 table in sync with main table + await db.execute(""" + CREATE TRIGGER IF NOT EXISTS memory_entries_ai AFTER INSERT ON memory_entries BEGIN + INSERT INTO memory_entries_fts(rowid, app_name, user_id, text_content) + VALUES (new.id, new.app_name, new.user_id, new.text_content); + END + """) + + await db.execute(""" + CREATE TRIGGER IF NOT EXISTS memory_entries_ad AFTER DELETE ON memory_entries BEGIN + INSERT INTO memory_entries_fts(memory_entries_fts, rowid, app_name, user_id, text_content) + VALUES('delete', old.id, old.app_name, old.user_id, old.text_content); + END + """) + + await db.execute(""" + CREATE TRIGGER IF NOT EXISTS memory_entries_au AFTER UPDATE ON memory_entries BEGIN + INSERT INTO memory_entries_fts(memory_entries_fts, rowid, app_name, user_id, text_content) + VALUES('delete', old.id, old.app_name, old.user_id, old.text_content); + INSERT INTO memory_entries_fts(rowid, app_name, user_id, text_content) + VALUES (new.id, new.app_name, new.user_id, new.text_content); + END + """) + + # Create index for timestamp ordering on main table + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_memory_timestamp + ON memory_entries(timestamp DESC) + """) + + await db.commit() + + @override + async def add_session_to_memory(self, session: Session): + """Adds a session to the SQLite memory store. + + A session may be added multiple times during its lifetime. Duplicate + entries are ignored based on unique constraints. + + Args: + session: The session to add to memory. + """ + await self._ensure_initialized() + + async with aiosqlite.connect(self._db_path) as db: + for event in session.events: + if not event.content or not event.content.parts: + continue + + # Extract text content for search + text_parts = [part.text for part in event.content.parts if part.text] + if not text_parts: + continue + + text_content = ' '.join(text_parts) + content_json = json.dumps( + event.content.model_dump(exclude_none=True, mode='json') + ) + + try: + await db.execute( + """ + INSERT OR IGNORE INTO memory_entries + (app_name, user_id, session_id, author, timestamp, + content_json, text_content) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + session.app_name, + session.user_id, + session.id, + event.author, + event.timestamp, + content_json, + text_content, + ), + ) + except aiosqlite.Error as e: + logger.error(f'Database error adding memory entry: {e}') + continue + except (json.JSONEncodeError, ValueError) as e: + logger.error(f'Error encoding memory entry content: {e}') + continue + + await db.commit() + logger.info( + f'Added session {session.id} to memory for user {session.user_id}' + ) + + @override + async def search_memory( + self, *, app_name: str, user_id: str, query: str + ) -> SearchMemoryResponse: + """Searches for memories using SQLite FTS5 full-text search. + + Args: + app_name: The name of the application. + user_id: The id of the user. + query: The query to search for. + + Returns: + A SearchMemoryResponse containing matching memories ordered by timestamp. + """ + await self._ensure_initialized() + + if not query.strip(): + return SearchMemoryResponse(memories=[]) + + # Prepare query for FTS5 + fts_query = _prepare_fts_query(query) + if not fts_query: + return SearchMemoryResponse(memories=[]) + + memories = [] + try: + async with aiosqlite.connect(self._db_path) as db: + # Use FTS5 for efficient full-text search + async with db.execute( + """ + SELECT m.author, m.timestamp, m.content_json + FROM memory_entries_fts f + JOIN memory_entries m ON f.rowid = m.id + WHERE f.memory_entries_fts MATCH ? + AND f.app_name = ? + AND f.user_id = ? + ORDER BY m.timestamp DESC + """, + (fts_query, app_name, user_id), + ) as cursor: + async for author, timestamp, content_json in cursor: + try: + content_dict = json.loads(content_json) + content = types.Content(**content_dict) + + memory_entry = MemoryEntry( + content=content, + author=author, + timestamp=_utils.format_timestamp(timestamp), + ) + memories.append(memory_entry) + except (json.JSONDecodeError, ValueError) as e: + logger.error(f'Error parsing memory entry content: {e}') + continue + + except aiosqlite.Error as e: + logger.error(f'Database error during search: {e}') + # Return empty response on database errors + return SearchMemoryResponse(memories=[]) + + return SearchMemoryResponse(memories=memories) + + async def clear_memory(self, app_name: str = None, user_id: str = None): + """Clears memory entries from the database. + + Args: + app_name: If specified, only clear entries for this app. + user_id: If specified, only clear entries for this user (requires app_name). + """ + await self._ensure_initialized() + + try: + async with aiosqlite.connect(self._db_path) as db: + if user_id and not app_name: + raise ValueError( + 'When clearing memory by user_id, app_name must also be provided.' + ) + + if app_name and user_id: + await db.execute( + 'DELETE FROM memory_entries WHERE app_name = ? AND user_id = ?', + (app_name, user_id), + ) + elif app_name: + await db.execute( + 'DELETE FROM memory_entries WHERE app_name = ?', (app_name,) + ) + else: + await db.execute('DELETE FROM memory_entries') + + await db.commit() + logger.info('Cleared memory entries from database') + except aiosqlite.Error as e: + logger.error(f'Database error clearing memory: {e}') + raise + + async def get_memory_stats(self) -> Dict[str, Any]: + """Returns statistics about the memory database. + + Returns: + Dictionary containing database statistics. + """ + await self._ensure_initialized() + + try: + async with aiosqlite.connect(self._db_path) as db: + # Total entries + async with db.execute('SELECT COUNT(*) FROM memory_entries') as cursor: + row = await cursor.fetchone() + total_entries = row[0] if row else 0 + + # Entries per app + entries_per_app = {} + async with db.execute(""" + SELECT app_name, COUNT(*) + FROM memory_entries + GROUP BY app_name + """) as cursor: + async for app_name, count in cursor: + entries_per_app[app_name] = count + + # Database file size + db_size_bytes = ( + self._db_path.stat().st_size if self._db_path.exists() else 0 + ) + + return { + 'total_entries': total_entries, + 'entries_per_app': entries_per_app, + 'database_file_size_bytes': db_size_bytes, + 'database_path': str(self._db_path), + } + except aiosqlite.Error as e: + logger.error(f'Database error getting stats: {e}') + return { + 'total_entries': 0, + 'entries_per_app': {}, + 'database_file_size_bytes': 0, + 'database_path': str(self._db_path), + 'error': str(e), + } diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py index f7a577afb2..52d6a055eb 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py @@ -164,8 +164,8 @@ def _dedupe_param_names(self): def _process_return_value(self) -> Parameter: """Returns a Parameter object representing the return type.""" responses = self._operation.responses or {} - # Default to Any if no 2xx response or if schema is missing - return_schema = Schema(type='Any') + # Default to object if no 2xx response or if schema is missing + return_schema = Schema(type='object') # Take the 20x response with the smallest response code. valid_codes = list( diff --git a/tests/unittests/memory/test_sqlite_memory_service.py b/tests/unittests/memory/test_sqlite_memory_service.py new file mode 100644 index 0000000000..a38d0cb88a --- /dev/null +++ b/tests/unittests/memory/test_sqlite_memory_service.py @@ -0,0 +1,445 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +import tempfile +from unittest.mock import AsyncMock +from unittest.mock import Mock + +import aiosqlite +from google.adk.events.event import Event +from google.adk.memory.sqlite_memory_service import _prepare_fts_query +from google.adk.memory.sqlite_memory_service import SqliteMemoryService +from google.adk.sessions.session import Session +from google.genai import types +import pytest + + +@pytest.fixture +def temp_db_path(): + """Provides a temporary database file path for testing.""" + with tempfile.NamedTemporaryFile(delete=False, suffix='.db') as tmp: + yield tmp.name + # Cleanup + Path(tmp.name).unlink(missing_ok=True) + + +@pytest.fixture +def sqlite_service(temp_db_path): + """Provides a SqliteMemoryService instance with temp database.""" + return SqliteMemoryService(db_path=temp_db_path) + + +@pytest.fixture +def sample_session(): + """Creates a sample session with events for testing.""" + session = Mock(spec=Session) + session.id = 'test_session_123' + session.app_name = 'test_app' + session.user_id = 'user_456' + + # Create test events + event1 = Mock(spec=Event) + event1.author = 'user' + event1.timestamp = 1640995200.0 # 2022-01-01 00:00:00 + event1.content = types.Content( + parts=[types.Part(text='Hello world')], role='user' + ) + + event2 = Mock(spec=Event) + event2.author = 'assistant' + event2.timestamp = 1640995260.0 # 2022-01-01 00:01:00 + event2.content = types.Content( + parts=[types.Part(text='How can I help you today?')], role='model' + ) + + # Event with no text parts (should be filtered out) + event3 = Mock(spec=Event) + event3.author = 'system' + event3.timestamp = 1640995320.0 + event3.content = types.Content(parts=[], role='system') + + session.events = [event1, event2, event3] + return session + + +@pytest.mark.asyncio +class TestSqliteMemoryService: + """Test suite for SqliteMemoryService with FTS5 functionality.""" + + async def test_init_creates_database(self, temp_db_path): + """Test that database is created during lazy initialization.""" + service = SqliteMemoryService(db_path=temp_db_path) + await service._ensure_initialized() + + assert Path(temp_db_path).exists() + # Verify both main table and FTS5 virtual table exist + async with aiosqlite.connect(temp_db_path) as db: + # Check main table + cursor = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND" + " name='memory_entries'" + ) + assert await cursor.fetchone() is not None + + # Check FTS5 virtual table + cursor = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND" + " name='memory_entries_fts'" + ) + assert await cursor.fetchone() is not None + + async def test_add_session_to_memory(self, sqlite_service, sample_session): + """Test adding a session to memory.""" + await sqlite_service.add_session_to_memory(sample_session) + + stats = await sqlite_service.get_memory_stats() + assert stats['total_entries'] == 2 # Only events with text content + assert stats['entries_per_app']['test_app'] == 2 + + async def test_search_memory_fts5_matching( + self, sqlite_service, sample_session + ): + """Test searching memory using FTS5 full-text search.""" + await sqlite_service.add_session_to_memory(sample_session) + + # Search for matching keyword + response = await sqlite_service.search_memory( + app_name='test_app', user_id='user_456', query='hello' + ) + + assert len(response.memories) == 1 + assert response.memories[0].author == 'user' + assert 'Hello world' in response.memories[0].content.parts[0].text + + async def test_search_memory_no_matches(self, sqlite_service, sample_session): + """Test searching memory with no matching keywords.""" + await sqlite_service.add_session_to_memory(sample_session) + + response = await sqlite_service.search_memory( + app_name='test_app', user_id='user_456', query='nonexistent' + ) + + assert len(response.memories) == 0 + + async def test_search_memory_empty_query( + self, sqlite_service, sample_session + ): + """Test searching memory with empty query.""" + await sqlite_service.add_session_to_memory(sample_session) + + # Empty query should return empty results + response = await sqlite_service.search_memory( + app_name='test_app', user_id='user_456', query='' + ) + + assert len(response.memories) == 0 + + # Whitespace-only query should return empty results + response = await sqlite_service.search_memory( + app_name='test_app', user_id='user_456', query=' \n\t ' + ) + + assert len(response.memories) == 0 + + async def test_search_memory_multiple_keywords( + self, sqlite_service, sample_session + ): + """Test searching memory with multiple keywords using FTS5 OR logic.""" + await sqlite_service.add_session_to_memory(sample_session) + + # Search with multiple keywords (FTS5 will use OR logic) + response = await sqlite_service.search_memory( + app_name='test_app', user_id='user_456', query='help today' + ) + + assert len(response.memories) == 1 + assert response.memories[0].author == 'assistant' + + # Search with keywords that should match both entries + response = await sqlite_service.search_memory( + app_name='test_app', user_id='user_456', query='hello help' + ) + + assert len(response.memories) == 2 # Should match both entries + + async def test_search_memory_user_isolation(self, sqlite_service): + """Test that memory search is isolated by user.""" + # Create sessions for different users + session1 = Mock(spec=Session) + session1.id = 'session1' + session1.app_name = 'test_app' + session1.user_id = 'user1' + session1.events = [ + Mock( + spec=Event, + author='user1', + timestamp=1640995200.0, + content=types.Content( + parts=[types.Part(text='user1 message')], role='user' + ), + ) + ] + + session2 = Mock(spec=Session) + session2.id = 'session2' + session2.app_name = 'test_app' + session2.user_id = 'user2' + session2.events = [ + Mock( + spec=Event, + author='user2', + timestamp=1640995200.0, + content=types.Content( + parts=[types.Part(text='user2 message')], role='user' + ), + ) + ] + + await sqlite_service.add_session_to_memory(session1) + await sqlite_service.add_session_to_memory(session2) + + # Search for user1 should only return user1's memories + response = await sqlite_service.search_memory( + app_name='test_app', user_id='user1', query='message' + ) + + assert len(response.memories) == 1 + assert response.memories[0].author == 'user1' + + async def test_clear_memory_all(self, sqlite_service, sample_session): + """Test clearing all memory entries.""" + await sqlite_service.add_session_to_memory(sample_session) + + stats_before = await sqlite_service.get_memory_stats() + assert stats_before['total_entries'] > 0 + + await sqlite_service.clear_memory() + + stats_after = await sqlite_service.get_memory_stats() + assert stats_after['total_entries'] == 0 + + async def test_clear_memory_by_app(self, sqlite_service): + """Test clearing memory entries by app name.""" + # Create sessions for different apps + session1 = Mock(spec=Session) + session1.id = 'session1' + session1.app_name = 'app1' + session1.user_id = 'user1' + session1.events = [ + Mock( + spec=Event, + author='user', + timestamp=1640995200.0, + content=types.Content( + parts=[types.Part(text='app1 message')], role='user' + ), + ) + ] + + session2 = Mock(spec=Session) + session2.id = 'session2' + session2.app_name = 'app2' + session2.user_id = 'user1' + session2.events = [ + Mock( + spec=Event, + author='user', + timestamp=1640995200.0, + content=types.Content( + parts=[types.Part(text='app2 message')], role='user' + ), + ) + ] + + await sqlite_service.add_session_to_memory(session1) + await sqlite_service.add_session_to_memory(session2) + + await sqlite_service.clear_memory(app_name='app1') + + stats = await sqlite_service.get_memory_stats() + assert stats['total_entries'] == 1 + assert 'app1' not in stats['entries_per_app'] + assert stats['entries_per_app']['app2'] == 1 + + async def test_clear_memory_by_user(self, sqlite_service): + """Test clearing memory entries by user within an app.""" + # Create sessions for different users in same app + session1 = Mock(spec=Session) + session1.id = 'session1' + session1.app_name = 'test_app' + session1.user_id = 'user1' + session1.events = [ + Mock( + spec=Event, + author='user1', + timestamp=1640995200.0, + content=types.Content( + parts=[types.Part(text='user1 message')], role='user' + ), + ) + ] + + session2 = Mock(spec=Session) + session2.id = 'session2' + session2.app_name = 'test_app' + session2.user_id = 'user2' + session2.events = [ + Mock( + spec=Event, + author='user2', + timestamp=1640995200.0, + content=types.Content( + parts=[types.Part(text='user2 message')], role='user' + ), + ) + ] + + await sqlite_service.add_session_to_memory(session1) + await sqlite_service.add_session_to_memory(session2) + + await sqlite_service.clear_memory(app_name='test_app', user_id='user1') + + # Verify user1's memories are gone but user2's remain + response = await sqlite_service.search_memory( + app_name='test_app', user_id='user1', query='message' + ) + assert len(response.memories) == 0 + + response = await sqlite_service.search_memory( + app_name='test_app', user_id='user2', query='message' + ) + assert len(response.memories) == 1 + + async def test_get_memory_stats(self, sqlite_service, sample_session): + """Test getting memory statistics.""" + await sqlite_service.add_session_to_memory(sample_session) + + stats = await sqlite_service.get_memory_stats() + + assert isinstance(stats, dict) + assert 'total_entries' in stats + assert 'entries_per_app' in stats + assert 'database_file_size_bytes' in stats + assert 'database_path' in stats + + assert stats['total_entries'] == 2 + assert stats['entries_per_app']['test_app'] == 2 + assert stats['database_file_size_bytes'] > 0 + + async def test_duplicate_session_handling( + self, sqlite_service, sample_session + ): + """Test that duplicate sessions are handled properly.""" + await sqlite_service.add_session_to_memory(sample_session) + await sqlite_service.add_session_to_memory(sample_session) # Add again + + stats = await sqlite_service.get_memory_stats() + # Should still be 2 entries due to UNIQUE constraint + assert stats['total_entries'] == 2 + + async def test_empty_content_filtering(self, sqlite_service): + """Test that events with empty content are filtered out.""" + session = Mock(spec=Session) + session.id = 'test_session' + session.app_name = 'test_app' + session.user_id = 'test_user' + + # Event with no content + event1 = Mock(spec=Event) + event1.content = None + event1.timestamp = 1640995200.0 + + # Event with empty parts + event2 = Mock(spec=Event) + event2.content = types.Content(parts=[], role='user') + event2.timestamp = 1640995260.0 + + # Valid event + event3 = Mock(spec=Event) + event3.author = 'user' + event3.timestamp = 1640995320.0 + event3.content = types.Content( + parts=[types.Part(text='Valid message')], role='user' + ) + + session.events = [event1, event2, event3] + + await sqlite_service.add_session_to_memory(session) + + stats = await sqlite_service.get_memory_stats() + assert stats['total_entries'] == 1 # Only the valid event + + async def test_lazy_initialization(self, sqlite_service): + """Test that database is initialized only once using lazy initialization.""" + # Database should not exist initially + assert not sqlite_service._initialized + + # First operation should initialize + stats1 = await sqlite_service.get_memory_stats() + assert sqlite_service._initialized + assert stats1['total_entries'] == 0 + + # Subsequent operations should not reinitialize + stats2 = await sqlite_service.get_memory_stats() + assert stats1 == stats2 + + async def test_fts_query_preparation(self): + """Test FTS5 query preparation function.""" + # Test normal text + assert _prepare_fts_query('hello world') == '"hello" OR "world"' + + # Test special characters are filtered + assert _prepare_fts_query('hello! @world#') == '"hello" OR "world"' + + # Test empty/whitespace input + assert _prepare_fts_query('') == '' + assert _prepare_fts_query(' ') == '' + assert _prepare_fts_query('!!!@#$') == '' + + # Test numbers and alphanumeric + assert _prepare_fts_query('test123 abc456') == '"test123" OR "abc456"' + + async def test_database_error_handling(self, temp_db_path): + """Test proper error handling for database operations.""" + service = SqliteMemoryService(db_path=temp_db_path) + + # Test stats with database error (corrupted file) + # Create a corrupted database file + with open(temp_db_path, 'w') as f: + f.write('corrupted database content') + + # Should raise error during initialization + with pytest.raises(aiosqlite.Error): + await service.get_memory_stats() + + # Test error handling in get_memory_stats after successful initialization + service2 = SqliteMemoryService(db_path=temp_db_path + '_good') + await service2._ensure_initialized() # Initialize successfully first + + # Now corrupt the database file after initialization + with open(service2._db_path, 'w') as f: + f.write('corrupted after init') + + # Should handle error gracefully in get_memory_stats + stats = await service2.get_memory_stats() + assert 'error' in stats + assert stats['total_entries'] == 0 + + async def test_clear_memory_by_user_without_app_name_raises_error( + self, sqlite_service + ): + """Test that clearing memory by user_id without app_name raises ValueError.""" + with pytest.raises(ValueError, match='app_name must also be provided'): + await sqlite_service.clear_memory(user_id='user1') diff --git a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_operation_parser.py b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_operation_parser.py index 26cb944a22..460a6073ec 100644 --- a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_operation_parser.py +++ b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_operation_parser.py @@ -207,7 +207,7 @@ def test_process_return_value_no_2xx(sample_operation): parser = OperationParser(operation_no_2xx, should_parse=False) parser._process_return_value() assert parser._return_value is not None - assert parser._return_value.type_hint == 'Any' + assert parser._return_value.type_hint == 'Dict[str, Any]' def test_process_return_value_multiple_2xx(sample_operation): @@ -255,7 +255,7 @@ def test_process_return_value_no_content(sample_operation): ) parser = OperationParser(operation_no_content, should_parse=False) parser._process_return_value() - assert parser._return_value.type_hint == 'Any' + assert parser._return_value.type_hint == 'Dict[str, Any]' def test_process_return_value_no_schema(sample_operation): @@ -270,7 +270,7 @@ def test_process_return_value_no_schema(sample_operation): ) parser = OperationParser(operation_no_schema, should_parse=False) parser._process_return_value() - assert parser._return_value.type_hint == 'Any' + assert parser._return_value.type_hint == 'Dict[str, Any]' def test_get_function_name(sample_operation):