fix(python-client): async method parity and server keepalive timeout (#387)

* Fix async method parity and server keepalive timeout

The Python client's async methods were missing parameters available in
their sync counterparts, and the server's default keepalive timeout was
shorter than the client's, causing ServerDisconnectedError on reused
connections.

Server:
- Set uvicorn timeout_keep_alive to 30s (default was 5s). The Python
  client (aiohttp) has a 15s client-side keepalive, so the server must
  hold connections longer to prevent the client from writing to a
  closed socket.

Python client - async method parity:
- arecall(): add trace, query_timestamp, include_entities,
  include_chunks, max_entity_tokens, max_chunk_tokens. Return
  RecallResponse instead of list[RecallResult].
- areflect(): add max_tokens and response_schema.
- acreate_bank(): new async method.
- aset_mission(): new async method.
- adelete_bank(): new async method.

Tests:
- Add test verifying uvicorn keepalive timeout exceeds client default.
- Add async tests for arecall (include_chunks, include_entities, trace,
  full params), areflect (max_tokens, structured output), and
  adelete_bank.

* Fix flaky tag tests by using entity-rich content and asserting on tags

The tag tests were unreliable because:
- Generic content ("Project X meeting notes") was frequently collapsed
  during fact extraction, leaving no memories to recall
- Assertions checked LLM-rewritten text for literal substrings instead
  of checking tags, which is what the tests are actually verifying

Fix: use distinctive, entity-rich content (named people with specific
actions) that reliably survives fact extraction, and assert on tag
membership rather than text content.
This commit is contained in:
Chris Bartholomew 2026-02-17 10:00:14 -05:00 committed by GitHub
parent 6bad667344
commit 8114ef440e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 349 additions and 39 deletions

View file

@ -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

View file

@ -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 (

View file

@ -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)

View file

@ -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