diff --git a/hindsight-api/hindsight_api/main.py b/hindsight-api/hindsight_api/main.py index 1ae2eda0..81578def 100644 --- a/hindsight-api/hindsight_api/main.py +++ b/hindsight-api/hindsight_api/main.py @@ -351,6 +351,7 @@ def main(): "proxy_headers": args.proxy_headers, "ws": "wsproto", # Use wsproto instead of websockets to avoid deprecation warnings "loop": loop_impl, # Explicitly set event loop implementation + "timeout_keep_alive": 30, # Exceed aiohttp's 15s client timeout so the client always closes first } # Add optional parameters if provided diff --git a/hindsight-api/tests/test_main_module.py b/hindsight-api/tests/test_main_module.py index e5428f8e..b59c98ac 100644 --- a/hindsight-api/tests/test_main_module.py +++ b/hindsight-api/tests/test_main_module.py @@ -352,6 +352,47 @@ class TestMainModuleExtensionLoading: "main.py should use import string when workers > 1" assert uvicorn_calls[0]["workers"] == 2 + def test_main_sets_keepalive_timeout(self, monkeypatch): + """ + Verify that uvicorn is configured with timeout_keep_alive > aiohttp's + default client keepalive timeout (15s), so the server never closes + connections before the client does. + """ + monkeypatch.setenv("HINDSIGHT_API_WORKERS", "1") + monkeypatch.delenv("HINDSIGHT_API_TENANT_EXTENSION", raising=False) + + uvicorn_calls = [] + + def capture_uvicorn_run(**kwargs): + uvicorn_calls.append(kwargs) + + with patch("hindsight_api.main.MemoryEngine") as mock_engine, \ + patch("hindsight_api.main.create_app") as mock_create_app, \ + patch("hindsight_api.main._get_raw_config") as mock_get_config, \ + patch("hindsight_api.main.print_banner"), \ + patch("uvicorn.run", side_effect=capture_uvicorn_run): + + mock_config = MagicMock() + mock_config.host = "0.0.0.0" + mock_config.port = 8888 + mock_config.log_level = "info" + mock_config.mcp_enabled = False + mock_config.run_migrations_on_startup = False + mock_config.database_url = "postgresql://test:test@localhost/test" + mock_get_config.return_value = mock_config + mock_engine.return_value = MagicMock() + mock_create_app.return_value = MagicMock() + + with patch.object(sys, 'argv', ['hindsight-api']): + from hindsight_api.main import main + main() + + assert len(uvicorn_calls) == 1 + assert "timeout_keep_alive" in uvicorn_calls[0], \ + "uvicorn config must set timeout_keep_alive" + assert uvicorn_calls[0]["timeout_keep_alive"] > 15, \ + "timeout_keep_alive must exceed aiohttp's 15s client default" + # Mock extensions for testing from hindsight_api.extensions import ( diff --git a/hindsight-clients/python/hindsight_client/hindsight_client.py b/hindsight-clients/python/hindsight_client/hindsight_client.py index 175dcd85..c876b239 100644 --- a/hindsight-clients/python/hindsight_client/hindsight_client.py +++ b/hindsight-clients/python/hindsight_client/hindsight_client.py @@ -373,6 +373,55 @@ class Hindsight: # Async methods (native async, no _run_async wrapper) + async def acreate_bank( + self, + bank_id: str, + name: str | None = None, + mission: str | None = None, + disposition: dict[str, float] | None = None, + ) -> BankProfileResponse: + """Create or update a memory bank (async). + + Args: + bank_id: Unique identifier for the bank + name: Human-readable display name + mission: Instructions guiding what Hindsight should learn and remember (for mental models) + disposition: Optional disposition traits (skepticism, literalism, empathy) + """ + from hindsight_client_api.models import create_bank_request, disposition_traits + + disposition_obj = None + if disposition: + disposition_obj = disposition_traits.DispositionTraits(**disposition) + + request_obj = create_bank_request.CreateBankRequest( + name=name, + mission=mission, + disposition=disposition_obj, + ) + + return await self._banks_api.create_or_update_bank(bank_id, request_obj) + + async def aset_mission( + self, + bank_id: str, + mission: str, + ) -> BankProfileResponse: + """ + Set or update the mission for a memory bank (async). + + Args: + bank_id: The memory bank ID + mission: The mission text describing the agent's purpose + + Returns: + BankProfileResponse with updated bank profile + """ + from hindsight_client_api.models import create_bank_request + + request_obj = create_bank_request.CreateBankRequest(mission=mission) + return await self._banks_api.create_or_update_bank(bank_id, request_obj) + async def aretain_batch( self, bank_id: str, @@ -471,9 +520,15 @@ class Hindsight: types: list[str] | None = None, max_tokens: int = 4096, budget: str = "mid", + trace: bool = False, + query_timestamp: str | None = None, + include_entities: bool = False, + max_entity_tokens: int = 500, + include_chunks: bool = False, + max_chunk_tokens: int = 8192, tags: list[str] | None = None, tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any", - ) -> list[RecallResult]: + ) -> RecallResponse: """ Recall memories using semantic similarity (async). @@ -483,25 +538,41 @@ class Hindsight: types: Optional list of fact types to filter (world, experience, opinion, observation) max_tokens: Maximum tokens in results (default: 4096) budget: Budget level for recall - "low", "mid", or "high" (default: "mid") + trace: Enable trace output (default: False) + query_timestamp: Optional ISO format date string (e.g., '2023-05-30T23:40:00') + include_entities: Include entity observations in results (default: False) + max_entity_tokens: Maximum tokens for entity observations (default: 500) + include_chunks: Include raw text chunks in results (default: False) + max_chunk_tokens: Maximum tokens for chunks (default: 8192) tags: Optional list of tags to filter memories by tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged), "any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any" Returns: - List of RecallResult objects + RecallResponse with results, optional entities, optional chunks, and optional trace """ + from hindsight_client_api.models import chunk_include_options, entity_include_options, include_options + + include_opts = include_options.IncludeOptions( + entities=entity_include_options.EntityIncludeOptions(max_tokens=max_entity_tokens) + if include_entities + else None, + chunks=chunk_include_options.ChunkIncludeOptions(max_tokens=max_chunk_tokens) if include_chunks else None, + ) + request_obj = recall_request.RecallRequest( query=query, types=types, budget=budget, max_tokens=max_tokens, - trace=False, + trace=trace, + query_timestamp=query_timestamp, + include=include_opts, tags=tags, tags_match=tags_match, ) - response = await self._memory_api.recall_memories(bank_id, request_obj) - return response.results if hasattr(response, "results") else [] + return await self._memory_api.recall_memories(bank_id, request_obj) async def areflect( self, @@ -509,6 +580,8 @@ class Hindsight: query: str, budget: str = "low", context: str | None = None, + max_tokens: int | None = None, + response_schema: dict[str, Any] | None = None, tags: list[str] | None = None, tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any", ) -> ReflectResponse: @@ -520,17 +593,24 @@ class Hindsight: query: The question or prompt budget: Budget level for reflection - "low", "mid", or "high" (default: "low") context: Optional additional context + max_tokens: Maximum tokens for the response (server default: 4096) + response_schema: Optional JSON Schema for structured output. When provided, + the response will include a 'structured_output' field with the LLM + response parsed according to this schema. tags: Optional list of tags to filter memories by tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged), "any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any" Returns: - ReflectResponse with answer text and optionally facts used + ReflectResponse with answer text, optionally facts used, and optionally + structured_output if response_schema was provided """ request_obj = reflect_request.ReflectRequest( query=query, budget=budget, context=context, + max_tokens=max_tokens, + response_schema=response_schema, tags=tags, tags_match=tags_match, ) @@ -786,3 +866,12 @@ class Hindsight: bank_id: The memory bank ID """ return _run_async(self._banks_api.delete_bank(bank_id)) + + async def adelete_bank(self, bank_id: str): + """ + Delete a memory bank (async). + + Args: + bank_id: The memory bank ID + """ + return await self._banks_api.delete_bank(bank_id) diff --git a/hindsight-clients/python/tests/test_main_operations.py b/hindsight-clients/python/tests/test_main_operations.py index f2f0b0f9..9aaece76 100644 --- a/hindsight-clients/python/tests/test_main_operations.py +++ b/hindsight-clients/python/tests/test_main_operations.py @@ -522,11 +522,11 @@ class TestTags: client.retain_batch( bank_id=bank_id, items=[ - {"content": "Project X meeting notes from Monday", "tags": ["project_x", "meetings"]}, - {"content": "Project X design document", "tags": ["project_x", "docs"]}, - {"content": "Project Y sprint planning", "tags": ["project_y", "meetings"]}, - {"content": "General company announcement", "tags": ["company"]}, - {"content": "Untagged memory about random things"}, # no tags + {"content": "Alice presented the Q3 roadmap at the Monday standup", "tags": ["project_x", "meetings"]}, + {"content": "Bob wrote the architecture document for the new auth system", "tags": ["project_x", "docs"]}, + {"content": "Charlie led the sprint planning session for the mobile app", "tags": ["project_y", "meetings"]}, + {"content": "Diana announced the company picnic for next Friday", "tags": ["company"]}, + {"content": "Eve mentioned she likes pineapple on pizza"}, # no tags ], retain_async=False, ) @@ -535,68 +535,73 @@ class TestTags: """Test recall with tags using 'any' match (includes untagged).""" response = client.recall( bank_id=bank_id, - query="What are the documents?", + query="What has everyone been working on?", tags=["project_x"], tags_match="any", + max_tokens=16000, ) assert response is not None assert response.results is not None - # Should include project_x tagged items and potentially untagged items - result_texts = [r.text.lower() for r in response.results] - assert any("project x" in text for text in result_texts) + assert len(response.results) > 0 + # 'any' mode: results should include items matching the tag or untagged items + result_tags = [set(r.tags) if r.tags else set() for r in response.results] + assert any("project_x" in tags for tags in result_tags) def test_recall_with_tags_any_strict(self, client, bank_id): """Test recall with tags using 'any_strict' match (excludes untagged).""" response = client.recall( bank_id=bank_id, - query="meetings", + query="What has everyone been working on?", tags=["project_x"], tags_match="any_strict", + max_tokens=16000, ) assert response is not None assert response.results is not None - # All results should have project_x tag - no untagged items - result_texts = [r.text.lower() for r in response.results] - # Should find project_x items only - for text in result_texts: - assert "project x" in text or "untagged" not in text + assert len(response.results) > 0 + # any_strict: every result must have the project_x tag + for r in response.results: + assert r.tags is not None + assert "project_x" in r.tags def test_recall_with_tags_all_strict(self, client, bank_id): """Test recall with tags using 'all_strict' match (AND matching).""" response = client.recall( bank_id=bank_id, - query="meeting notes", + query="What has everyone been working on?", tags=["project_x", "meetings"], tags_match="all_strict", + max_tokens=16000, ) assert response is not None assert response.results is not None - # Should only return items tagged with BOTH project_x AND meetings - if len(response.results) > 0: - result_texts = [r.text.lower() for r in response.results] - # The "Project X meeting notes" should be found - assert any("project x" in text and "meeting" in text for text in result_texts) + assert len(response.results) > 0 + # all_strict: every result must have BOTH tags + for r in response.results: + assert r.tags is not None + assert "project_x" in r.tags + assert "meetings" in r.tags def test_recall_with_multiple_tags_any(self, client, bank_id): - """Test recall with multiple tags using 'any' match (OR).""" + """Test recall with multiple tags using 'any_strict' match (OR).""" response = client.recall( bank_id=bank_id, - query="What's happening?", + query="What has everyone been working on?", tags=["project_x", "project_y"], tags_match="any_strict", + max_tokens=16000, ) assert response is not None assert response.results is not None - # Should include items from both project_x and project_y - result_texts = [r.text.lower() for r in response.results] - has_project_x = any("project x" in text for text in result_texts) - has_project_y = any("project y" in text for text in result_texts) - # At least one of them should be present - assert has_project_x or has_project_y + assert len(response.results) > 0 + # any_strict with multiple tags: every result must have at least one of the tags + for r in response.results: + assert r.tags is not None + assert "project_x" in r.tags or "project_y" in r.tags def test_reflect_with_tags(self, client, bank_id): """Test reflect with tags filtering.""" @@ -615,7 +620,7 @@ class TestTags: """Test storing a memory with tags.""" response = client.retain( bank_id=bank_id, - content="New feature implementation for project Z", + content="Frank deployed the billing microservice to production on Tuesday", tags=["project_z", "features"], ) @@ -625,13 +630,17 @@ class TestTags: # Verify we can recall it with the tag recall_response = client.recall( bank_id=bank_id, - query="project Z features", + query="What did Frank deploy?", tags=["project_z"], tags_match="any_strict", + max_tokens=16000, ) assert recall_response is not None - result_texts = [r.text.lower() for r in recall_response.results] - assert any("project z" in text for text in result_texts) + assert len(recall_response.results) > 0 + # any_strict: every result must have the project_z tag + for r in recall_response.results: + assert r.tags is not None + assert "project_z" in r.tags def test_retain_batch_with_document_tags(self, client, bank_id): """Test batch retain with document-level tags.""" @@ -702,3 +711,173 @@ class TestMission: assert response is not None assert response.bank_id == bank_id assert response.mission == "Be a helpful PM tracking sprint progress and team capacity" + + +class TestAsyncRecall: + """Tests for async recall with full feature parity.""" + + @pytest.fixture(autouse=True) + async def setup_memories(self, client, bank_id): + """Setup: Store some test memories before search tests.""" + await client.aretain_batch( + bank_id=bank_id, + items=[ + {"content": "Alice loves programming in Python"}, + {"content": "Bob enjoys hiking and outdoor adventures"}, + {"content": "Charlie is interested in quantum physics"}, + ], + ) + + @pytest.mark.asyncio + async def test_arecall_returns_recall_response(self, client, bank_id): + """Test that arecall returns a RecallResponse (not a list).""" + from hindsight_client_api.models.recall_response import RecallResponse + + response = await client.arecall( + bank_id=bank_id, + query="What does Alice like?", + ) + + assert isinstance(response, RecallResponse) + assert response.results is not None + assert len(response.results) > 0 + + @pytest.mark.asyncio + async def test_arecall_with_include_chunks(self, client, bank_id): + """Test arecall with include_chunks returns raw text chunks.""" + response = await client.arecall( + bank_id=bank_id, + query="programming", + include_chunks=True, + ) + + assert response is not None + assert response.results is not None + # chunks should be present (may be empty dict if not yet consolidated) + assert response.chunks is not None + + @pytest.mark.asyncio + async def test_arecall_with_include_entities(self, client, bank_id): + """Test arecall with include_entities.""" + response = await client.arecall( + bank_id=bank_id, + query="Alice", + include_entities=True, + max_entity_tokens=500, + ) + + assert response is not None + assert response.results is not None + + @pytest.mark.asyncio + async def test_arecall_with_trace(self, client, bank_id): + """Test arecall with trace enabled.""" + response = await client.arecall( + bank_id=bank_id, + query="outdoor activities", + trace=True, + ) + + assert response is not None + assert response.results is not None + + @pytest.mark.asyncio + async def test_arecall_full_featured(self, client, bank_id): + """Test arecall with all parameters.""" + response = await client.arecall( + bank_id=bank_id, + query="What are people's interests?", + types=["world"], + max_tokens=2048, + budget="high", + trace=True, + include_chunks=True, + max_chunk_tokens=4096, + include_entities=True, + max_entity_tokens=500, + ) + + assert response is not None + assert response.results is not None + assert response.chunks is not None + + +class TestAsyncReflect: + """Tests for async reflect with full feature parity.""" + + @pytest.fixture(autouse=True) + async def setup_memories(self, client, bank_id): + """Setup: Store some test memories and bank background.""" + await client.acreate_bank( + bank_id=bank_id, + mission="I am a helpful AI assistant interested in technology and science.", + ) + + await client.aretain_batch( + bank_id=bank_id, + items=[ + {"content": "The Python programming language is great for data science"}, + {"content": "Machine learning models can recognize patterns in data"}, + ], + ) + + @pytest.mark.asyncio + async def test_areflect_with_max_tokens(self, client, bank_id): + """Test areflect with max_tokens parameter.""" + response = await client.areflect( + bank_id=bank_id, + query="What do you think about Python?", + max_tokens=500, + ) + + assert response is not None + assert response.text is not None + assert len(response.text) > 0 + + @pytest.mark.asyncio + async def test_areflect_with_structured_output(self, client, bank_id): + """Test areflect with response_schema for structured output.""" + from pydantic import BaseModel + + class RecommendationResponse(BaseModel): + recommendation: str + reasons: list[str] + confidence: str | None = None + + response = await client.areflect( + bank_id=bank_id, + query="What programming language should I learn for data science?", + response_schema=RecommendationResponse.model_json_schema(), + max_tokens=10000, + ) + + assert response is not None + assert response.structured_output is not None + result = RecommendationResponse.model_validate(response.structured_output) + assert result.recommendation + assert isinstance(result.reasons, list) + + +class TestAsyncDeleteBank: + """Tests for async bank deletion.""" + + @pytest.mark.asyncio + async def test_adelete_bank(self, client): + """Test deleting a bank using the async method.""" + bank_id = f"test_bank_adelete_{uuid.uuid4().hex[:12]}" + + # Create bank with some data using async API + await client.acreate_bank( + bank_id=bank_id, + mission="This bank will be deleted via async", + ) + await client.aretain( + bank_id=bank_id, + content="Some memory to store", + ) + + # Delete using async method + response = await client.adelete_bank(bank_id=bank_id) + + assert response is not None + assert response.success is True