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:
parent
6bad667344
commit
8114ef440e
4 changed files with 349 additions and 39 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue