From 62f6bd444b1cc48178573c1f223f4b4f956d1e14 Mon Sep 17 00:00:00 2001 From: rdwj Date: Thu, 23 Apr 2026 16:00:44 -0500 Subject: [PATCH 01/16] feat: Add MemoryStore protocol for cross-session agent memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a MemoryStore abstraction that complements ContextStore. ContextStore handles per-conversation message replay; MemoryStore handles durable, governed knowledge that persists across sessions. The protocol consists of: - MemoryStore (ABC): factory that creates per-context instances - MemoryStoreInstance (Protocol): search, write, read, update, delete - MemoryResult (Pydantic model): returned from search and read The interface is backend-agnostic — implementations can target any persistent memory service. Assisted-By: Claude Code (Opus 4.6) Signed-off-by: rdwj --- apps/adk-py/pyproject.toml | 3 + .../src/kagenti_adk/server/store/__init__.py | 15 + .../kagenti_adk/server/store/memory_store.py | 88 +++ .../server/store/memoryhub_memory_store.py | 239 ++++++++ apps/adk-py/uv.lock | 534 +++++++++++++++++- 5 files changed, 878 insertions(+), 1 deletion(-) create mode 100644 apps/adk-py/src/kagenti_adk/server/store/memory_store.py create mode 100644 apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py diff --git a/apps/adk-py/pyproject.toml b/apps/adk-py/pyproject.toml index 90442b72a..842466c85 100644 --- a/apps/adk-py/pyproject.toml +++ b/apps/adk-py/pyproject.toml @@ -32,6 +32,9 @@ dependencies = [ "jsonpatch>=1.33", ] +[project.optional-dependencies] +memoryhub = ["memoryhub>=0.5.0"] + [dependency-groups] dev = [ "beeai-framework[duckduckgo,wikipedia]>=0.1.76", diff --git a/apps/adk-py/src/kagenti_adk/server/store/__init__.py b/apps/adk-py/src/kagenti_adk/server/store/__init__.py index 7200f7b62..b41a9336e 100644 --- a/apps/adk-py/src/kagenti_adk/server/store/__init__.py +++ b/apps/adk-py/src/kagenti_adk/server/store/__init__.py @@ -3,3 +3,18 @@ from __future__ import annotations + +from kagenti_adk.server.store.context_store import ContextStore, ContextStoreInstance +from kagenti_adk.server.store.memory_store import ( + MemoryResult, + MemoryStore, + MemoryStoreInstance, +) + +__all__ = [ + "ContextStore", + "ContextStoreInstance", + "MemoryResult", + "MemoryStore", + "MemoryStoreInstance", +] diff --git a/apps/adk-py/src/kagenti_adk/server/store/memory_store.py b/apps/adk-py/src/kagenti_adk/server/store/memory_store.py new file mode 100644 index 000000000..0a79e3123 --- /dev/null +++ b/apps/adk-py/src/kagenti_adk/server/store/memory_store.py @@ -0,0 +1,88 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +"""Long-term governed memory store abstraction for AI agents. + +This module defines the MemoryStore protocol — a complement to ContextStore +that handles durable, cross-session knowledge rather than per-context +conversation replay. ContextStore answers "what was said in this conversation"; +MemoryStore answers "what does this agent know across all conversations." + +The protocol is backend-agnostic. The MemoryHub implementation in +memoryhub_memory_store.py is one concrete backend; others (Redis, SQLite, +in-memory for testing) can implement the same interface. +""" + +from __future__ import annotations + +import abc +from typing import Protocol + +from pydantic import BaseModel + +__all__ = [ + "MemoryResult", + "MemoryStore", + "MemoryStoreInstance", +] + + +class MemoryResult(BaseModel): + """A single memory returned from search or read.""" + + memory_id: str + content: str + scope: str + weight: float = 0.7 + relevance_score: float | None = None + + +class MemoryStoreInstance(Protocol): + """Operations on governed memory, scoped to a context. + + Each method maps to a standard memory lifecycle operation. + Implementations should raise backend-specific errors for + authorization failures or validation issues. + """ + + async def search( + self, + query: str, + *, + scope: str | None = None, + project_id: str | None = None, + max_results: int = 10, + ) -> list[MemoryResult]: ... + + async def write( + self, + content: str, + *, + scope: str = "user", + weight: float = 0.7, + tags: list[str] | None = None, + project_id: str | None = None, + ) -> str: + """Write a memory. Returns the new memory_id.""" + ... + + async def read(self, memory_id: str) -> MemoryResult | None: ... + + async def update(self, memory_id: str, content: str) -> None: ... + + async def delete(self, memory_id: str) -> None: ... + + +class MemoryStore(abc.ABC): + """Factory that creates MemoryStoreInstance objects per context. + + Mirrors the ContextStore pattern: the factory holds connection config, + create() returns a per-context instance. + """ + + @property + def required_extensions(self) -> set[str]: + return set() + + @abc.abstractmethod + async def create(self, context_id: str) -> MemoryStoreInstance: ... diff --git a/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py new file mode 100644 index 000000000..1f7864eb0 --- /dev/null +++ b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py @@ -0,0 +1,239 @@ +# Copyright 2026 Wes Jackson +# SPDX-License-Identifier: Apache-2.0 + +"""MemoryStore backed by MemoryHub (https://github.com/redhat-ai-americas/memory-hub). + +Wraps the ``memoryhub`` Python SDK to provide governed, cross-session memory +to ADK agents via the MemoryStore protocol. Requires the ``memoryhub`` extra: + + pip install kagenti-adk[memoryhub] + +Authentication is configured via environment variables: + + OAuth 2.1 (recommended): + MEMORYHUB_URL, MEMORYHUB_AUTH_URL, MEMORYHUB_CLIENT_ID, MEMORYHUB_CLIENT_SECRET + + API key (dev/testing): + MEMORYHUB_URL, MEMORYHUB_API_KEY +""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING + +from kagenti_adk.server.store.memory_store import MemoryResult, MemoryStore, MemoryStoreInstance + +if TYPE_CHECKING: + from memoryhub.client import MemoryHubClient + +logger = logging.getLogger(__name__) + +__all__ = [ + "MemoryHubMemoryStore", + "MemoryHubMemoryStoreInstance", + "create_memory_dependency", +] + + +class MemoryHubMemoryStoreInstance: + """Per-context memory operations backed by MemoryHub.""" + + def __init__(self, context_id: str, client: MemoryHubClient) -> None: + self._context_id = context_id + self._client = client + + async def search( + self, + query: str, + *, + scope: str | None = None, + project_id: str | None = None, + max_results: int = 10, + ) -> list[MemoryResult]: + result = await self._client.search( + query, + scope=scope, + project_id=project_id, + max_results=max_results, + ) + return [ + MemoryResult( + memory_id=m.id, + content=m.content or m.stub or "", + scope=m.scope, + weight=m.weight, + relevance_score=m.relevance_score, + ) + for m in result.results + ] + + async def write( + self, + content: str, + *, + scope: str = "user", + weight: float = 0.7, + tags: list[str] | None = None, + project_id: str | None = None, + ) -> str: + result = await self._client.write( + content, + scope=scope, + weight=weight, + domains=tags, + project_id=project_id, + ) + if result.memory is None: + # Curation gated the write — return empty string to signal no-op + logger.warning("MemoryHub curation gated write: %s", result.curation.reason) + return "" + return result.memory.id + + async def read(self, memory_id: str) -> MemoryResult | None: + from memoryhub.exceptions import NotFoundError + + try: + m = await self._client.read(memory_id) + except NotFoundError: + return None + return MemoryResult( + memory_id=m.id, + content=m.content or "", + scope=m.scope, + weight=m.weight, + ) + + async def update(self, memory_id: str, content: str) -> None: + await self._client.update(memory_id, content=content) + + async def delete(self, memory_id: str) -> None: + await self._client.delete(memory_id) + + +class MemoryHubMemoryStore(MemoryStore): + """Factory for MemoryHub-backed memory store instances. + + Holds connection configuration and lazily creates the MemoryHubClient + on first use. The client is shared across all instances (contexts) + because it manages its own auth token lifecycle. + """ + + def __init__( + self, + *, + url: str | None = None, + auth_url: str | None = None, + client_id: str | None = None, + client_secret: str | None = None, + api_key: str | None = None, + ) -> None: + self._url = url + self._auth_url = auth_url + self._client_id = client_id + self._client_secret = client_secret + self._api_key = api_key + self._client: MemoryHubClient | None = None + + @classmethod + def from_env(cls) -> MemoryHubMemoryStore: + """Create from MEMORYHUB_* environment variables.""" + return cls( + url=os.environ.get("MEMORYHUB_URL"), + auth_url=os.environ.get("MEMORYHUB_AUTH_URL"), + client_id=os.environ.get("MEMORYHUB_CLIENT_ID"), + client_secret=os.environ.get("MEMORYHUB_CLIENT_SECRET"), + api_key=os.environ.get("MEMORYHUB_API_KEY"), + ) + + async def _get_client(self) -> MemoryHubClient: + if self._client is None: + from memoryhub.client import MemoryHubClient + + if self._api_key: + self._client = MemoryHubClient( + url=self._url, + api_key=self._api_key, + ) + else: + self._client = MemoryHubClient( + url=self._url, + auth_url=self._auth_url, + client_id=self._client_id, + client_secret=self._client_secret, + ) + await self._client.__aenter__() + logger.info("MemoryHub client connected to %s", self._url) + return self._client + + async def create(self, context_id: str) -> MemoryStoreInstance: + client = await self._get_client() + return MemoryHubMemoryStoreInstance(context_id=context_id, client=client) + + +class _MemoryProxy: + """Lazy-initializing proxy that resolves the MemoryStoreInstance on first use. + + The ADK's Depends framework calls the dependency callable synchronously and + yields the return value. Since MemoryStore.create() is async, we can't call + it during dependency resolution. Instead, we return this proxy which lazily + awaits create() on the first method call. + """ + + def __init__(self, store: MemoryHubMemoryStore, context_id: str) -> None: + self._store = store + self._context_id = context_id + self._instance: MemoryHubMemoryStoreInstance | None = None + + async def _resolve(self) -> MemoryHubMemoryStoreInstance: + if self._instance is None: + self._instance = await self._store.create(self._context_id) + return self._instance + + async def search(self, query, **kwargs): + inst = await self._resolve() + return await inst.search(query, **kwargs) + + async def write(self, content, **kwargs): + inst = await self._resolve() + return await inst.write(content, **kwargs) + + async def read(self, memory_id): + inst = await self._resolve() + return await inst.read(memory_id) + + async def update(self, memory_id, content): + inst = await self._resolve() + return await inst.update(memory_id, content) + + async def delete(self, memory_id): + inst = await self._resolve() + return await inst.delete(memory_id) + + +def create_memory_dependency(store: MemoryHubMemoryStore): + """Create a DI-compatible dependency provider for the ADK Depends pattern. + + Returns a synchronous callable (required by ADK's Depends) that produces + a lazy-initializing proxy. The proxy resolves the MemoryStoreInstance + on first async method call. + + Usage:: + + memory_store = MemoryHubMemoryStore.from_env() + memory_dep = create_memory_dependency(memory_store) + + @server.agent() + async def my_agent( + input: Message, + context: RunContext, + memory: Annotated[MemoryHubMemoryStoreInstance, Depends(memory_dep)], + ): + results = await memory.search("user preferences") + """ + + def provider(message, context, request_context): + return _MemoryProxy(store, context.context_id) + + return provider diff --git a/apps/adk-py/uv.lock b/apps/adk-py/uv.lock index 8a70c4f01..2ea209f47 100644 --- a/apps/adk-py/uv.lock +++ b/apps/adk-py/uv.lock @@ -7,7 +7,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-10T07:48:23.382492377Z" +exclude-newer = "2026-04-20T21:00:29.769605Z" exclude-newer-span = "P3D" [[package]] @@ -33,6 +33,18 @@ sqlite = [ { name = "sqlalchemy", extra = ["aiosqlite", "asyncio"] }, ] +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + [[package]] name = "aiofiles" version = "24.1.0" @@ -257,6 +269,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + [[package]] name = "beeai-framework" version = "0.1.79" @@ -295,6 +325,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" }, ] +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, + { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -552,6 +607,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] +[[package]] +name = "cyclopts" +version = "4.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/2c/fced34890f6e5a93a4b7afb2c71e8eee2a0719fb26193a0abf159ecb714d/cyclopts-4.10.2.tar.gz", hash = "sha256:d7b950457ef2563596d56331f80cbbbf86a2772535fb8b315c4f03bc7e6127f1", size = 166664, upload-time = "2026-04-08T23:57:45.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/bd/05055d8360cef0757d79367157f3b15c0a0715e81e08f86a04018ec045f0/cyclopts-4.10.2-py3-none-any.whl", hash = "sha256:a1f2d6f8f7afac9456b48f75a40b36658778ddc9c6d406b520d017ae32c990fe", size = 204314, upload-time = "2026-04-08T23:57:46.969Z" }, +] + [[package]] name = "ddgs" version = "9.11.4" @@ -587,6 +657,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "fastapi" version = "0.135.2" @@ -603,6 +725,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, ] +[[package]] +name = "fastmcp" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/13/29544fbc6dfe45ea38046af0067311e0bad7acc7d1f2ad38bb08f2409fe2/fastmcp-3.2.4.tar.gz", hash = "sha256:083ecb75b44a4169e7fc0f632f94b781bdb0ff877c6b35b9877cbb566fd4d4d1", size = 28746127, upload-time = "2026-04-14T01:42:24.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/76/b310d52fa0e30d39bd937eb58ec2c1f1ea1b5f519f0575e9dd9612f01deb/fastmcp-3.2.4-py3-none-any.whl", hash = "sha256:e6c9c429171041455e47ab94bb3f83c4657622a0ec28922f6940053959bd58a9", size = 728599, upload-time = "2026-04-14T01:42:26.85Z" }, +] + [[package]] name = "fastuuid" version = "0.14.0" @@ -871,6 +1026,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1008,6 +1172,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/34/65604740edcb20e1bda6a890348ed7d282e7dd23aa00401cbe36fd0edbd9/janus-2.0.0-py3-none-any.whl", hash = "sha256:7e6449d34eab04cd016befbd7d8c0d8acaaaab67cb59e076a69149f9031745f9", size = 12161, upload-time = "2024-12-13T12:59:06.106Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1168,6 +1377,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] +[[package]] +name = "jsonschema-path" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/8a/7e6102f2b8bdc6705a9eb5294f8f6f9ccd3a8420e8e8e19671d1dd773251/jsonschema_path-0.4.5.tar.gz", hash = "sha256:c6cd7d577ae290c7defd4f4029e86fdb248ca1bd41a07557795b3c95e5144918", size = 15113, upload-time = "2026-03-03T09:56:46.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/d5/4e96c44f6c1ea3d812cf5391d81a4f5abaa540abf8d04ecd7f66e0ed11df/jsonschema_path-0.4.5-py3-none-any.whl", hash = "sha256:7d77a2c3f3ec569a40efe5c5f942c44c1af2a6f96fe0866794c9ef5b8f87fd65", size = 19368, upload-time = "2026-03-03T09:56:45.39Z" }, +] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -1211,6 +1434,11 @@ dependencies = [ { name = "uvicorn" }, ] +[package.optional-dependencies] +memoryhub = [ + { name = "memoryhub" }, +] + [package.dev-dependencies] dev = [ { name = "beeai-framework", extra = ["duckduckgo", "wikipedia"] }, @@ -1235,6 +1463,7 @@ requires-dist = [ { name = "janus", specifier = ">=2.0.0" }, { name = "jsonpatch", specifier = ">=1.33" }, { name = "mcp", specifier = ">=1.12.3" }, + { name = "memoryhub", marker = "extra == 'memoryhub'", specifier = ">=0.5.0" }, { name = "objprint", specifier = ">=0.3.0" }, { name = "opentelemetry-api", specifier = ">=1.35.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.35.0" }, @@ -1248,6 +1477,7 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.15.0" }, { name = "uvicorn", specifier = ">=0.37.0,<0.43.0" }, ] +provides-extras = ["memoryhub"] [package.metadata.requires-dev] dev = [ @@ -1259,6 +1489,24 @@ dev = [ { name = "ruff", specifier = ">=0.15.0" }, ] +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "litellm" version = "1.83.0" @@ -1504,6 +1752,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "memoryhub" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastmcp" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/09/b1c0426ed7983e53487c4dacb5701ea0412f171c7121fd6a9b8194013682/memoryhub-0.5.1.tar.gz", hash = "sha256:6e4a8596446731e86992ba4ac436b6676668405b9fc6a592ce4c7d2d456cfbe2", size = 37448, upload-time = "2026-04-14T17:15:32.996Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1e/7107ec51dc7f43142a51976d77993c690906963f805eca147ab89419499a/memoryhub-0.5.1-py3-none-any.whl", hash = "sha256:f3297bdcc50f52f1c0abc0d00d4252f4c29ea9c26a8dcd3b9fd2858976fd95fa", size = 22209, upload-time = "2026-04-14T17:15:31.74Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -1649,6 +1922,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533, upload-time = "2026-03-17T17:53:47.348Z" }, ] +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.40.0" @@ -1840,6 +2125,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pathable" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2013,6 +2316,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] +[[package]] +name = "py-key-value-aio" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + [[package]] name = "pyasn1" version = "0.6.3" @@ -2058,6 +2386,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.41.5" @@ -2192,6 +2525,15 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pyrefly" version = "0.58.0" @@ -2287,6 +2629,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2488,6 +2839,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + [[package]] name = "rpds-py" version = "0.30.0" @@ -2621,6 +2985,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -2865,6 +3242,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "uncalled-for" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/68/35c1d87e608940badbcfeb630347aa0509897284684f61fab6423d02b253/uncalled_for-0.3.1.tar.gz", hash = "sha256:5e412ac6708f04b56bef5867b5dcf6690ebce4eb7316058d9c50787492bb4bca", size = 49693, upload-time = "2026-04-07T13:05:06.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/e1/7ec67882ad8fc9f86384bef6421fa252c9cbe5744f8df6ce77afc9eca1f5/uncalled_for-0.3.1-py3-none-any.whl", hash = "sha256:074cdc92da8356278f93d0ded6f2a66dd883dbecaf9bc89437646ee2289cc200", size = 11361, upload-time = "2026-04-07T13:05:05.341Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -2887,6 +3273,152 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "wikipedia-api" version = "0.8.1" From 6d3968265818373103340957a236fc6536a2bfd1 Mon Sep 17 00:00:00 2001 From: rdwj Date: Thu, 23 Apr 2026 16:01:05 -0500 Subject: [PATCH 02/16] test: Add unit tests for MemoryStore protocol and MemoryHub implementation 40 tests covering: - MemoryResult model (field defaults, explicit overrides) - MemoryHubMemoryStore (construction, from_env, client caching) - MemoryHubMemoryStoreInstance (search, write, read, update, delete) - _MemoryProxy (lazy init, method delegation, curation gating) - create_memory_dependency (sync provider, proxy resolution) The memoryhub SDK is mocked at the module level to avoid requiring the optional dependency in the test environment. Assisted-By: Claude Code (Opus 4.6) Signed-off-by: rdwj --- .../tests/unit/server/store/__init__.py | 2 + .../unit/server/store/test_memory_store.py | 564 ++++++++++++++++++ 2 files changed, 566 insertions(+) create mode 100644 apps/adk-py/tests/unit/server/store/__init__.py create mode 100644 apps/adk-py/tests/unit/server/store/test_memory_store.py diff --git a/apps/adk-py/tests/unit/server/store/__init__.py b/apps/adk-py/tests/unit/server/store/__init__.py new file mode 100644 index 000000000..7abe8ca66 --- /dev/null +++ b/apps/adk-py/tests/unit/server/store/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 diff --git a/apps/adk-py/tests/unit/server/store/test_memory_store.py b/apps/adk-py/tests/unit/server/store/test_memory_store.py new file mode 100644 index 000000000..df43f4e6d --- /dev/null +++ b/apps/adk-py/tests/unit/server/store/test_memory_store.py @@ -0,0 +1,564 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for MemoryStore protocol and MemoryHub implementation. + +The memoryhub SDK is an optional dependency, so we patch it at the module +level before importing the implementation. All tests are async-compatible +via pytest-asyncio's asyncio_mode = "auto" (set in pyproject.toml). +""" + +from __future__ import annotations + +import sys +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from kagenti_adk.server.store.memory_store import MemoryResult + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Helpers — build a fake memoryhub module tree so the implementation can be +# imported and used without the real SDK installed. +# --------------------------------------------------------------------------- + + +def _make_memoryhub_stubs() -> tuple[ModuleType, MagicMock]: + """Return (memoryhub package stub, MemoryHubClient class mock).""" + client_mock = MagicMock() + + memoryhub = ModuleType("memoryhub") + memoryhub_client = ModuleType("memoryhub.client") + memoryhub_exceptions = ModuleType("memoryhub.exceptions") + + # NotFoundError used in the read() implementation + class NotFoundError(Exception): + pass + + memoryhub_exceptions.NotFoundError = NotFoundError + memoryhub_client.MemoryHubClient = client_mock + + memoryhub.client = memoryhub_client + memoryhub.exceptions = memoryhub_exceptions + + return memoryhub, client_mock, NotFoundError + + +def _install_stubs(memoryhub, client_mod, exc_mod): + sys.modules.setdefault("memoryhub", memoryhub) + sys.modules.setdefault("memoryhub.client", client_mod) + sys.modules.setdefault("memoryhub.exceptions", exc_mod) + + +# Build stubs once for the module lifetime. +_memoryhub_stub, _ClientClass, _NotFoundError = _make_memoryhub_stubs() +_install_stubs(_memoryhub_stub, _memoryhub_stub.client, _memoryhub_stub.exceptions) + +# Now it is safe to import the implementation. +from kagenti_adk.server.store.memoryhub_memory_store import ( # noqa: E402 + MemoryHubMemoryStore, + MemoryHubMemoryStoreInstance, + _MemoryProxy, + create_memory_dependency, +) + + +# --------------------------------------------------------------------------- +# Factories +# --------------------------------------------------------------------------- + + +def _mock_client() -> AsyncMock: + """Return an async mock that looks like a MemoryHubClient.""" + client = AsyncMock() + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=False) + return client + + +def _search_result(*memories) -> SimpleNamespace: + return SimpleNamespace(results=list(memories)) + + +def _memory_obj( + id: str = "mem-1", + content: str = "some content", + scope: str = "user", + weight: float = 0.7, + relevance_score: float | None = None, + stub: str | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + id=id, + content=content, + scope=scope, + weight=weight, + relevance_score=relevance_score, + stub=stub, + ) + + +def _write_result(memory=None, curation=None) -> SimpleNamespace: + if curation is None: + curation = SimpleNamespace(reason="") + return SimpleNamespace(memory=memory, curation=curation) + + +# --------------------------------------------------------------------------- +# MemoryResult model +# --------------------------------------------------------------------------- + + +class TestMemoryResult: + def test_required_fields(self): + r = MemoryResult(memory_id="id-1", content="hello", scope="user") + assert r.memory_id == "id-1" + assert r.content == "hello" + assert r.scope == "user" + + def test_weight_default(self): + r = MemoryResult(memory_id="x", content="y", scope="project") + assert r.weight == 0.7 + + def test_relevance_score_default_is_none(self): + r = MemoryResult(memory_id="x", content="y", scope="project") + assert r.relevance_score is None + + def test_explicit_weight_and_relevance(self): + r = MemoryResult(memory_id="x", content="y", scope="org", weight=0.9, relevance_score=0.85) + assert r.weight == 0.9 + assert r.relevance_score == 0.85 + + +# --------------------------------------------------------------------------- +# MemoryHubMemoryStore construction and from_env +# --------------------------------------------------------------------------- + + +class TestMemoryHubMemoryStore: + def test_direct_construction_stores_params(self): + store = MemoryHubMemoryStore(url="http://hub", api_key="key-123") + assert store._url == "http://hub" + assert store._api_key == "key-123" + assert store._client is None + + def test_from_env_reads_api_key(self, monkeypatch): + monkeypatch.setenv("MEMORYHUB_URL", "http://hub.example.com") + monkeypatch.setenv("MEMORYHUB_API_KEY", "secret-key") + monkeypatch.delenv("MEMORYHUB_AUTH_URL", raising=False) + monkeypatch.delenv("MEMORYHUB_CLIENT_ID", raising=False) + monkeypatch.delenv("MEMORYHUB_CLIENT_SECRET", raising=False) + + store = MemoryHubMemoryStore.from_env() + assert store._url == "http://hub.example.com" + assert store._api_key == "secret-key" + assert store._auth_url is None + + def test_from_env_reads_oauth_vars(self, monkeypatch): + monkeypatch.setenv("MEMORYHUB_URL", "http://hub.example.com") + monkeypatch.setenv("MEMORYHUB_AUTH_URL", "http://auth.example.com") + monkeypatch.setenv("MEMORYHUB_CLIENT_ID", "client-id") + monkeypatch.setenv("MEMORYHUB_CLIENT_SECRET", "client-secret") + monkeypatch.delenv("MEMORYHUB_API_KEY", raising=False) + + store = MemoryHubMemoryStore.from_env() + assert store._auth_url == "http://auth.example.com" + assert store._client_id == "client-id" + assert store._client_secret == "client-secret" + assert store._api_key is None + + def test_from_env_missing_vars_yields_none(self, monkeypatch): + for var in ("MEMORYHUB_URL", "MEMORYHUB_API_KEY", "MEMORYHUB_AUTH_URL", + "MEMORYHUB_CLIENT_ID", "MEMORYHUB_CLIENT_SECRET"): + monkeypatch.delenv(var, raising=False) + + store = MemoryHubMemoryStore.from_env() + assert store._url is None + assert store._api_key is None + + async def test_create_returns_instance(self): + store = MemoryHubMemoryStore(url="http://hub", api_key="k") + client = _mock_client() + store._client = client + + inst = await store.create("ctx-1") + assert isinstance(inst, MemoryHubMemoryStoreInstance) + assert inst._context_id == "ctx-1" + assert inst._client is client + + async def test_get_client_uses_api_key_path(self): + store = MemoryHubMemoryStore(url="http://hub", api_key="my-key") + fake_client = _mock_client() + _ClientClass.return_value = fake_client + + with patch("memoryhub.client.MemoryHubClient", _ClientClass): + client = await store._get_client() + + _ClientClass.assert_called_once_with(url="http://hub", api_key="my-key") + assert store._client is fake_client + + async def test_get_client_uses_oauth_path(self): + store = MemoryHubMemoryStore( + url="http://hub", + auth_url="http://auth", + client_id="cid", + client_secret="csec", + ) + fake_client = _mock_client() + _ClientClass.reset_mock() + _ClientClass.return_value = fake_client + + with patch("memoryhub.client.MemoryHubClient", _ClientClass): + client = await store._get_client() + + _ClientClass.assert_called_once_with( + url="http://hub", + auth_url="http://auth", + client_id="cid", + client_secret="csec", + ) + assert store._client is fake_client + + async def test_get_client_is_cached(self): + store = MemoryHubMemoryStore(url="http://hub", api_key="k") + fake_client = _mock_client() + store._client = fake_client + + _ClientClass.reset_mock() + client = await store._get_client() + assert client is fake_client + # Pre-populated _client — constructor must not be called again. + _ClientClass.assert_not_called() + + +# --------------------------------------------------------------------------- +# MemoryHubMemoryStoreInstance operations +# --------------------------------------------------------------------------- + + +class TestMemoryHubMemoryStoreInstance: + def _make(self, client=None) -> MemoryHubMemoryStoreInstance: + return MemoryHubMemoryStoreInstance( + context_id="ctx-99", + client=client or _mock_client(), + ) + + # --- search --- + + async def test_search_maps_results(self): + client = _mock_client() + client.search.return_value = _search_result( + _memory_obj(id="m1", content="alpha", scope="user", weight=0.8, relevance_score=0.9), + _memory_obj(id="m2", content="beta", scope="project", weight=0.6, relevance_score=0.7), + ) + inst = self._make(client) + + results = await inst.search("test query") + + client.search.assert_awaited_once_with( + "test query", scope=None, project_id=None, max_results=10 + ) + assert len(results) == 2 + assert results[0] == MemoryResult(memory_id="m1", content="alpha", scope="user", + weight=0.8, relevance_score=0.9) + assert results[1] == MemoryResult(memory_id="m2", content="beta", scope="project", + weight=0.6, relevance_score=0.7) + + async def test_search_passes_optional_kwargs(self): + client = _mock_client() + client.search.return_value = _search_result() + inst = self._make(client) + + await inst.search("q", scope="project", project_id="proj-1", max_results=5) + + client.search.assert_awaited_once_with( + "q", scope="project", project_id="proj-1", max_results=5 + ) + + async def test_search_falls_back_to_stub_when_content_none(self): + client = _mock_client() + client.search.return_value = _search_result( + _memory_obj(id="m1", content=None, stub="stub text", scope="user") + ) + inst = self._make(client) + + results = await inst.search("q") + assert results[0].content == "stub text" + + async def test_search_empty_string_when_both_none(self): + client = _mock_client() + client.search.return_value = _search_result( + _memory_obj(id="m1", content=None, stub=None, scope="user") + ) + inst = self._make(client) + + results = await inst.search("q") + assert results[0].content == "" + + async def test_search_empty_results(self): + client = _mock_client() + client.search.return_value = _search_result() + inst = self._make(client) + + results = await inst.search("nothing") + assert results == [] + + # --- write --- + + async def test_write_returns_memory_id(self): + client = _mock_client() + client.write.return_value = _write_result( + memory=SimpleNamespace(id="new-id-42") + ) + inst = self._make(client) + + memory_id = await inst.write("important fact") + + client.write.assert_awaited_once_with( + "important fact", + scope="user", + weight=0.7, + domains=None, + project_id=None, + ) + assert memory_id == "new-id-42" + + async def test_write_passes_all_params(self): + client = _mock_client() + client.write.return_value = _write_result(memory=SimpleNamespace(id="x")) + inst = self._make(client) + + await inst.write( + "content", + scope="project", + weight=0.9, + tags=["infra", "k8s"], + project_id="proj-1", + ) + + client.write.assert_awaited_once_with( + "content", + scope="project", + weight=0.9, + domains=["infra", "k8s"], + project_id="proj-1", + ) + + async def test_write_returns_empty_string_when_curation_blocks(self): + client = _mock_client() + client.write.return_value = _write_result( + memory=None, + curation=SimpleNamespace(reason="duplicate detected"), + ) + inst = self._make(client) + + memory_id = await inst.write("duplicate content") + assert memory_id == "" + + # --- read --- + + async def test_read_returns_memory_result(self): + client = _mock_client() + client.read.return_value = _memory_obj( + id="m-read", content="stored fact", scope="user", weight=0.8 + ) + inst = self._make(client) + + result = await inst.read("m-read") + + client.read.assert_awaited_once_with("m-read") + assert result == MemoryResult( + memory_id="m-read", content="stored fact", scope="user", weight=0.8 + ) + + async def test_read_returns_none_for_not_found(self): + client = _mock_client() + client.read.side_effect = _NotFoundError("not found") + inst = self._make(client) + + result = await inst.read("missing-id") + assert result is None + + async def test_read_empty_content_defaults_to_empty_string(self): + client = _mock_client() + client.read.return_value = _memory_obj(id="x", content=None, scope="user") + inst = self._make(client) + + result = await inst.read("x") + assert result.content == "" + + # --- update --- + + async def test_update_delegates_to_client(self): + client = _mock_client() + inst = self._make(client) + + await inst.update("m-1", "new content") + + client.update.assert_awaited_once_with("m-1", content="new content") + + async def test_update_returns_none(self): + client = _mock_client() + client.update.return_value = None + inst = self._make(client) + + result = await inst.update("m-1", "content") + assert result is None + + # --- delete --- + + async def test_delete_delegates_to_client(self): + client = _mock_client() + inst = self._make(client) + + await inst.delete("m-1") + + client.delete.assert_awaited_once_with("m-1") + + async def test_delete_returns_none(self): + client = _mock_client() + client.delete.return_value = None + inst = self._make(client) + + result = await inst.delete("m-1") + assert result is None + + +# --------------------------------------------------------------------------- +# _MemoryProxy — lazy initialization and method delegation +# --------------------------------------------------------------------------- + + +class TestMemoryProxy: + def _make_store_and_proxy(self, client=None) -> tuple[MemoryHubMemoryStore, _MemoryProxy]: + store = MemoryHubMemoryStore(url="http://hub", api_key="k") + store._client = client or _mock_client() + proxy = _MemoryProxy(store=store, context_id="ctx-proxy") + return store, proxy + + async def test_instance_is_none_before_first_call(self): + _, proxy = self._make_store_and_proxy() + assert proxy._instance is None + + async def test_resolve_creates_instance_once(self): + _, proxy = self._make_store_and_proxy() + + inst1 = await proxy._resolve() + inst2 = await proxy._resolve() + + assert inst1 is inst2 + assert isinstance(inst1, MemoryHubMemoryStoreInstance) + + async def test_search_delegates_to_instance(self): + client = _mock_client() + client.search.return_value = _search_result( + _memory_obj(id="p1", content="proxy content", scope="user") + ) + _, proxy = self._make_store_and_proxy(client) + + results = await proxy.search("via proxy") + assert len(results) == 1 + assert results[0].memory_id == "p1" + + async def test_write_delegates_to_instance(self): + client = _mock_client() + client.write.return_value = _write_result(memory=SimpleNamespace(id="proxy-id")) + _, proxy = self._make_store_and_proxy(client) + + memory_id = await proxy.write("proxy write") + assert memory_id == "proxy-id" + + async def test_read_delegates_to_instance(self): + client = _mock_client() + client.read.return_value = _memory_obj(id="r1", content="c", scope="user") + _, proxy = self._make_store_and_proxy(client) + + result = await proxy.read("r1") + assert result.memory_id == "r1" + + async def test_update_delegates_to_instance(self): + client = _mock_client() + _, proxy = self._make_store_and_proxy(client) + + await proxy.update("m-1", "updated") + client.update.assert_awaited_once_with("m-1", content="updated") + + async def test_delete_delegates_to_instance(self): + client = _mock_client() + _, proxy = self._make_store_and_proxy(client) + + await proxy.delete("m-del") + client.delete.assert_awaited_once_with("m-del") + + async def test_curation_blocked_write_via_proxy(self): + client = _mock_client() + client.write.return_value = _write_result( + memory=None, + curation=SimpleNamespace(reason="blocked"), + ) + _, proxy = self._make_store_and_proxy(client) + + memory_id = await proxy.write("blocked content") + assert memory_id == "" + + +# --------------------------------------------------------------------------- +# create_memory_dependency +# --------------------------------------------------------------------------- + + +class TestCreateMemoryDependency: + def test_returns_callable(self): + store = MemoryHubMemoryStore(url="http://hub", api_key="k") + dep = create_memory_dependency(store) + assert callable(dep) + + def test_provider_returns_proxy(self): + store = MemoryHubMemoryStore(url="http://hub", api_key="k") + dep = create_memory_dependency(store) + + fake_context = SimpleNamespace(context_id="ctx-dep-1") + proxy = dep(message=None, context=fake_context, request_context=None) + + assert isinstance(proxy, _MemoryProxy) + assert proxy._store is store + assert proxy._context_id == "ctx-dep-1" + + def test_provider_uses_context_id_from_context(self): + store = MemoryHubMemoryStore(url="http://hub", api_key="k") + dep = create_memory_dependency(store) + + proxy_a = dep(None, SimpleNamespace(context_id="ctx-A"), None) + proxy_b = dep(None, SimpleNamespace(context_id="ctx-B"), None) + + assert proxy_a._context_id == "ctx-A" + assert proxy_b._context_id == "ctx-B" + + def test_each_call_returns_new_proxy(self): + store = MemoryHubMemoryStore(url="http://hub", api_key="k") + dep = create_memory_dependency(store) + + ctx = SimpleNamespace(context_id="ctx-same") + p1 = dep(None, ctx, None) + p2 = dep(None, ctx, None) + + assert p1 is not p2 + + async def test_proxy_from_dependency_resolves_correctly(self): + store = MemoryHubMemoryStore(url="http://hub", api_key="k") + client = _mock_client() + store._client = client + client.search.return_value = _search_result( + _memory_obj(id="dep-m1", content="dep content", scope="user") + ) + + dep = create_memory_dependency(store) + proxy = dep(None, SimpleNamespace(context_id="ctx-resolve"), None) + + results = await proxy.search("dep query") + assert len(results) == 1 + assert results[0].memory_id == "dep-m1" From bf7c6c79b045efa89e7548a82d9dc852cbfbd741 Mon Sep 17 00:00:00 2001 From: rdwj Date: Thu, 23 Apr 2026 16:01:14 -0500 Subject: [PATCH 03/16] docs: Add MemoryStore development guide Brief doc covering the MemoryStore protocol, MemoryHub implementation, DI integration via create_memory_dependency(), and a minimal agent example. Assisted-By: Claude Code (Opus 4.6) Signed-off-by: rdwj --- .../agent-integration/memory-store.mdx | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/development/agent-integration/memory-store.mdx diff --git a/docs/development/agent-integration/memory-store.mdx b/docs/development/agent-integration/memory-store.mdx new file mode 100644 index 000000000..d02fbf898 --- /dev/null +++ b/docs/development/agent-integration/memory-store.mdx @@ -0,0 +1,159 @@ +--- +title: "Memory Store" +description: "Give agents durable, cross-session memory backed by a governed knowledge store." +--- + +`MemoryStore` is a complement to `ContextStore`. While `ContextStore` replays the current conversation (per-context, ephemeral), `MemoryStore` provides durable knowledge that persists across sessions — things the agent should remember regardless of which conversation is active. + +The interface is backend-agnostic. The built-in implementation uses [MemoryHub](https://github.com/redhat-ai-americas/memory-hub), an open-source governed memory service for AI agents. + +## Protocol + +Two types make up the abstraction: + +**`MemoryStore`** — an abstract factory that holds connection configuration and creates per-context instances: + +```python +class MemoryStore(abc.ABC): + @abc.abstractmethod + async def create(self, context_id: str) -> MemoryStoreInstance: ... +``` + +**`MemoryStoreInstance`** — the per-context operations protocol: + +```python +class MemoryStoreInstance(Protocol): + async def search(self, query: str, *, scope: str | None = None, + project_id: str | None = None, max_results: int = 10) -> list[MemoryResult]: ... + async def write(self, content: str, *, scope: str = "user", weight: float = 0.7, + tags: list[str] | None = None, project_id: str | None = None) -> str: ... + async def read(self, memory_id: str) -> MemoryResult | None: ... + async def update(self, memory_id: str, content: str) -> None: ... + async def delete(self, memory_id: str) -> None: ... +``` + +`write()` returns the new `memory_id`, or an empty string if the backend's curation policy rejected the write. + +## MemoryHub implementation + +Install the optional dependency: + +```bash +pip install kagenti-adk[memoryhub] +``` + +Create a store from environment variables: + +```python +from kagenti_adk.server.store.memoryhub_memory_store import MemoryHubMemoryStore + +memory_store = MemoryHubMemoryStore.from_env() +``` + +### Environment variables + +OAuth 2.1 (recommended for production): + +| Variable | Description | +|---|---| +| `MEMORYHUB_URL` | MemoryHub service URL | +| `MEMORYHUB_AUTH_URL` | OAuth 2.1 token endpoint | +| `MEMORYHUB_CLIENT_ID` | OAuth client ID | +| `MEMORYHUB_CLIENT_SECRET` | OAuth client secret | + +API key (dev/testing): + +| Variable | Description | +|---|---| +| `MEMORYHUB_URL` | MemoryHub service URL | +| `MEMORYHUB_API_KEY` | Static API key | + +When both are present, the API key takes precedence. + +## DI integration + +ADK's `Depends` mechanism is synchronous but `MemoryStore.create()` is async. `create_memory_dependency()` returns a sync provider that wraps the store in a lazy proxy — the underlying `MemoryStoreInstance` is resolved on the first async method call. See [kagenti/adk#229](https://github.com/kagenti/adk/issues/229) for background. + +```python +from kagenti_adk.server.store.memoryhub_memory_store import ( + MemoryHubMemoryStore, + create_memory_dependency, +) + +memory_store = MemoryHubMemoryStore.from_env() +memory_dep = create_memory_dependency(memory_store) +``` + +Pass the dependency to your agent using `Annotated` + `Depends`: + +```python +from typing import Annotated +from kagenti_adk.server.dependencies import Depends + +@server.agent() +async def my_agent( + input: Message, + context: RunContext, + memory: Annotated[MemoryHubMemoryStoreInstance, Depends(memory_dep)], +): + ... +``` + +## Example + +A minimal agent that loads relevant context before responding and saves new facts: + +```python +import os +from typing import Annotated + +from a2a.types import Message +from kagenti_adk.server import Server +from kagenti_adk.server.context import RunContext +from kagenti_adk.server.dependencies import Depends +from kagenti_adk.server.store.memoryhub_memory_store import ( + MemoryHubMemoryStore, + MemoryHubMemoryStoreInstance, + create_memory_dependency, +) + +server = Server() +memory_store = MemoryHubMemoryStore.from_env() +memory_dep = create_memory_dependency(memory_store) + + +@server.agent() +async def memory_agent( + input: Message, + context: RunContext, + memory: Annotated[MemoryHubMemoryStoreInstance, Depends(memory_dep)], +): + query = input.parts[0].text + + # Load relevant memories before processing + results = await memory.search(query, max_results=5) + context_block = "\n".join(r.content for r in results) + + # ... call LLM with context_block + query ... + + # Persist anything worth remembering + await memory.write( + "User prefers concise responses", + scope="user", + weight=0.8, + ) + + yield f"[recalled {len(results)} memories]\n..." + + +def run(): + server.run(host=os.getenv("HOST", "127.0.0.1"), port=int(os.getenv("PORT", 8000))) + + +if __name__ == "__main__": + run() +``` + + +Use `scope="project"` with a `project_id` to share memories across multiple agents working on the same project, rather than scoping them to a single user. + From 766aa67e1baf11ab0b2192cc9bb856f69cc33d75 Mon Sep 17 00:00:00 2001 From: rdwj Date: Sat, 25 Apr 2026 21:37:50 -0500 Subject: [PATCH 04/16] fix: Address review feedback for MemoryStore PR - MemoryHubMemoryStoreInstance now subclasses MemoryStoreInstance, matching the ADK convention (MemoryContextStoreInstance, etc.) - Add close() to MemoryHubMemoryStore for client session cleanup - Add full type annotations to _MemoryProxy methods and DI provider - Fix ruff lint errors: unused variables (F841), import sort (I001) - Add tests for close() (cleanup and no-op paths) - Move doc to docs/development/sdk/memory.mdx and register in docs.json Assisted-by: Claude Code (Opus 4.6) Signed-off-by: rdwj --- .../server/store/memoryhub_memory_store.py | 46 ++++++++++++++----- .../unit/server/store/test_memory_store.py | 22 +++++++-- .../memory-store.mdx => sdk/memory.mdx} | 0 docs/docs.json | 3 +- 4 files changed, 56 insertions(+), 15 deletions(-) rename docs/development/{agent-integration/memory-store.mdx => sdk/memory.mdx} (100%) diff --git a/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py index 1f7864eb0..c04e347e9 100644 --- a/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py +++ b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py @@ -1,4 +1,4 @@ -# Copyright 2026 Wes Jackson +# Copyright 2026 © IBM Corp. # SPDX-License-Identifier: Apache-2.0 """MemoryStore backed by MemoryHub (https://github.com/redhat-ai-americas/memory-hub). @@ -21,13 +21,16 @@ import logging import os -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from kagenti_adk.server.store.memory_store import MemoryResult, MemoryStore, MemoryStoreInstance if TYPE_CHECKING: + from a2a.types import Message from memoryhub.client import MemoryHubClient + from kagenti_adk.server.context import RunContext + logger = logging.getLogger(__name__) __all__ = [ @@ -37,7 +40,7 @@ ] -class MemoryHubMemoryStoreInstance: +class MemoryHubMemoryStoreInstance(MemoryStoreInstance): """Per-context memory operations backed by MemoryHub.""" def __init__(self, context_id: str, client: MemoryHubClient) -> None: @@ -167,6 +170,12 @@ async def _get_client(self) -> MemoryHubClient: logger.info("MemoryHub client connected to %s", self._url) return self._client + async def close(self) -> None: + """Close the underlying MemoryHub client session.""" + if self._client is not None: + await self._client.__aexit__(None, None, None) + self._client = None + async def create(self, context_id: str) -> MemoryStoreInstance: client = await self._get_client() return MemoryHubMemoryStoreInstance(context_id=context_id, client=client) @@ -191,23 +200,38 @@ async def _resolve(self) -> MemoryHubMemoryStoreInstance: self._instance = await self._store.create(self._context_id) return self._instance - async def search(self, query, **kwargs): + async def search( + self, + query: str, + *, + scope: str | None = None, + project_id: str | None = None, + max_results: int = 10, + ) -> list[MemoryResult]: inst = await self._resolve() - return await inst.search(query, **kwargs) + return await inst.search(query, scope=scope, project_id=project_id, max_results=max_results) - async def write(self, content, **kwargs): + async def write( + self, + content: str, + *, + scope: str = "user", + weight: float = 0.7, + tags: list[str] | None = None, + project_id: str | None = None, + ) -> str: inst = await self._resolve() - return await inst.write(content, **kwargs) + return await inst.write(content, scope=scope, weight=weight, tags=tags, project_id=project_id) - async def read(self, memory_id): + async def read(self, memory_id: str) -> MemoryResult | None: inst = await self._resolve() return await inst.read(memory_id) - async def update(self, memory_id, content): + async def update(self, memory_id: str, content: str) -> None: inst = await self._resolve() return await inst.update(memory_id, content) - async def delete(self, memory_id): + async def delete(self, memory_id: str) -> None: inst = await self._resolve() return await inst.delete(memory_id) @@ -233,7 +257,7 @@ async def my_agent( results = await memory.search("user preferences") """ - def provider(message, context, request_context): + def provider(message: Message, context: RunContext, request_context: Any) -> _MemoryProxy: return _MemoryProxy(store, context.context_id) return provider diff --git a/apps/adk-py/tests/unit/server/store/test_memory_store.py b/apps/adk-py/tests/unit/server/store/test_memory_store.py index df43f4e6d..b36f574f4 100644 --- a/apps/adk-py/tests/unit/server/store/test_memory_store.py +++ b/apps/adk-py/tests/unit/server/store/test_memory_store.py @@ -59,7 +59,7 @@ def _install_stubs(memoryhub, client_mod, exc_mod): _install_stubs(_memoryhub_stub, _memoryhub_stub.client, _memoryhub_stub.exceptions) # Now it is safe to import the implementation. -from kagenti_adk.server.store.memoryhub_memory_store import ( # noqa: E402 +from kagenti_adk.server.store.memoryhub_memory_store import ( # noqa: E402, I001 MemoryHubMemoryStore, MemoryHubMemoryStoreInstance, _MemoryProxy, @@ -196,7 +196,7 @@ async def test_get_client_uses_api_key_path(self): _ClientClass.return_value = fake_client with patch("memoryhub.client.MemoryHubClient", _ClientClass): - client = await store._get_client() + await store._get_client() _ClientClass.assert_called_once_with(url="http://hub", api_key="my-key") assert store._client is fake_client @@ -213,7 +213,7 @@ async def test_get_client_uses_oauth_path(self): _ClientClass.return_value = fake_client with patch("memoryhub.client.MemoryHubClient", _ClientClass): - client = await store._get_client() + await store._get_client() _ClientClass.assert_called_once_with( url="http://hub", @@ -223,6 +223,22 @@ async def test_get_client_uses_oauth_path(self): ) assert store._client is fake_client + async def test_close_cleans_up_client(self): + store = MemoryHubMemoryStore(url="http://hub", api_key="k") + fake_client = _mock_client() + store._client = fake_client + + await store.close() + + fake_client.__aexit__.assert_awaited_once_with(None, None, None) + assert store._client is None + + async def test_close_noop_when_no_client(self): + store = MemoryHubMemoryStore(url="http://hub", api_key="k") + assert store._client is None + await store.close() # should not raise + assert store._client is None + async def test_get_client_is_cached(self): store = MemoryHubMemoryStore(url="http://hub", api_key="k") fake_client = _mock_client() diff --git a/docs/development/agent-integration/memory-store.mdx b/docs/development/sdk/memory.mdx similarity index 100% rename from docs/development/agent-integration/memory-store.mdx rename to docs/development/sdk/memory.mdx diff --git a/docs/docs.json b/docs/docs.json index cf0cf1d44..7e754663e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -145,7 +145,8 @@ "development/sdk/error", "development/sdk/tool-calls", "development/sdk/canvas", - "development/sdk/observability" + "development/sdk/observability", + "development/sdk/memory" ] }, { From a327e12e792a549380654d274e3e015d8309d4b9 Mon Sep 17 00:00:00 2001 From: rdwj Date: Tue, 28 Apr 2026 06:29:21 -0500 Subject: [PATCH 05/16] adk-py: Support awaitable callables in Depends The Depends machinery resolved the dependency callable synchronously during __call__, which forced async dependency providers to bridge via proxy objects. Move resolution into the lifespan body and await any resulting awaitable so async providers are first-class. Refs #229. Assisted-by: Claude Code (Opus 4.7) --- .../src/kagenti_adk/server/dependencies.py | 5 +- apps/adk-py/tests/unit/test_dependencies.py | 73 ++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/apps/adk-py/src/kagenti_adk/server/dependencies.py b/apps/adk-py/src/kagenti_adk/server/dependencies.py index a06f6ad34..c7d1ea88d 100644 --- a/apps/adk-py/src/kagenti_adk/server/dependencies.py +++ b/apps/adk-py/src/kagenti_adk/server/dependencies.py @@ -45,10 +45,11 @@ def __init__( def __call__( self, message: Message, context: RunContext, request_context: RequestContext ) -> AbstractAsyncContextManager[Dependency]: - instance = self._dependency_callable(message, context, request_context) - @asynccontextmanager async def lifespan() -> AsyncGenerator[Dependency]: + instance = self._dependency_callable(message, context, request_context) + if inspect.isawaitable(instance): + instance = await instance if self.extension or hasattr(instance, "lifespan"): async with instance.lifespan(): yield instance diff --git a/apps/adk-py/tests/unit/test_dependencies.py b/apps/adk-py/tests/unit/test_dependencies.py index 362b10754..dfbe8d445 100644 --- a/apps/adk-py/tests/unit/test_dependencies.py +++ b/apps/adk-py/tests/unit/test_dependencies.py @@ -3,7 +3,9 @@ from __future__ import annotations +from contextlib import asynccontextmanager from typing import Annotated, TypedDict, Unpack +from unittest.mock import MagicMock import pytest @@ -14,7 +16,7 @@ TrajectoryExtensionSpec, ) from kagenti_adk.a2a.extensions.streaming import StreamingExtensionServer, StreamingExtensionSpec -from kagenti_adk.server.dependencies import extract_dependencies +from kagenti_adk.server.dependencies import Depends, extract_dependencies pytestmark = pytest.mark.unit @@ -64,3 +66,72 @@ def agent( pass assert extract_dependencies(agent).keys() == {"a", "b"} + + +# --------------------------------------------------------------------------- +# Depends supports awaitable callables (issue #229) +# --------------------------------------------------------------------------- + + +class TestDependsAwaitable: + """Depends should resolve both sync and async dependency callables.""" + + async def test_sync_callable_still_works(self) -> None: + sentinel = object() + dep = Depends(lambda _msg, _ctx, _rctx: sentinel) + async with dep(MagicMock(), MagicMock(), MagicMock()) as resolved: + assert resolved is sentinel + + async def test_async_callable_resolves_awaited_value(self) -> None: + sentinel = object() + + async def async_provider(_msg, _ctx, _rctx): + return sentinel + + dep = Depends(async_provider) + async with dep(MagicMock(), MagicMock(), MagicMock()) as resolved: + assert resolved is sentinel + + async def test_async_callable_returning_lifespan_object_enters_and_exits(self) -> None: + events: list[str] = [] + + class Lifespanned: + @asynccontextmanager + async def lifespan(self): + events.append("enter") + try: + yield + finally: + events.append("exit") + + instance = Lifespanned() + + async def async_provider(_msg, _ctx, _rctx): + return instance + + dep = Depends(async_provider) + async with dep(MagicMock(), MagicMock(), MagicMock()) as resolved: + assert resolved is instance + events.append("body") + + assert events == ["enter", "body", "exit"] + + async def test_sync_callable_returning_lifespan_object_regression(self) -> None: + """BaseExtensionServer-style: sync callable that returns an instance with .lifespan().""" + events: list[str] = [] + + class Lifespanned: + @asynccontextmanager + async def lifespan(self): + events.append("enter") + try: + yield + finally: + events.append("exit") + + instance = Lifespanned() + dep = Depends(lambda _msg, _ctx, _rctx: instance) + async with dep(MagicMock(), MagicMock(), MagicMock()) as resolved: + assert resolved is instance + + assert events == ["enter", "exit"] From 1e36b23273cddc0b1682cc0fa1fac6154764f2e7 Mon Sep 17 00:00:00 2001 From: rdwj Date: Tue, 28 Apr 2026 06:30:13 -0500 Subject: [PATCH 06/16] adk-py: Rename MemoryStoreInstance.write to create `create` more clearly distinguishes "make a new memory" from `update`, matching the read/create/update/delete vocabulary the rest of the protocol uses. The MemoryHub backend still calls the underlying SDK's `client.write()`; only the protocol surface changes. Assisted-by: Claude Code (Opus 4.7) --- .../kagenti_adk/server/store/memory_store.py | 4 ++-- .../server/store/memoryhub_memory_store.py | 10 ++++----- .../unit/server/store/test_memory_store.py | 22 +++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/adk-py/src/kagenti_adk/server/store/memory_store.py b/apps/adk-py/src/kagenti_adk/server/store/memory_store.py index 0a79e3123..764c5409a 100644 --- a/apps/adk-py/src/kagenti_adk/server/store/memory_store.py +++ b/apps/adk-py/src/kagenti_adk/server/store/memory_store.py @@ -54,7 +54,7 @@ async def search( max_results: int = 10, ) -> list[MemoryResult]: ... - async def write( + async def create( self, content: str, *, @@ -63,7 +63,7 @@ async def write( tags: list[str] | None = None, project_id: str | None = None, ) -> str: - """Write a memory. Returns the new memory_id.""" + """Create a new memory. Returns the new memory_id.""" ... async def read(self, memory_id: str) -> MemoryResult | None: ... diff --git a/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py index c04e347e9..ce8166fd7 100644 --- a/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py +++ b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py @@ -72,7 +72,7 @@ async def search( for m in result.results ] - async def write( + async def create( self, content: str, *, @@ -89,8 +89,8 @@ async def write( project_id=project_id, ) if result.memory is None: - # Curation gated the write — return empty string to signal no-op - logger.warning("MemoryHub curation gated write: %s", result.curation.reason) + # Curation gated the create — return empty string to signal no-op + logger.warning("MemoryHub curation gated create: %s", result.curation.reason) return "" return result.memory.id @@ -211,7 +211,7 @@ async def search( inst = await self._resolve() return await inst.search(query, scope=scope, project_id=project_id, max_results=max_results) - async def write( + async def create( self, content: str, *, @@ -221,7 +221,7 @@ async def write( project_id: str | None = None, ) -> str: inst = await self._resolve() - return await inst.write(content, scope=scope, weight=weight, tags=tags, project_id=project_id) + return await inst.create(content, scope=scope, weight=weight, tags=tags, project_id=project_id) async def read(self, memory_id: str) -> MemoryResult | None: inst = await self._resolve() diff --git a/apps/adk-py/tests/unit/server/store/test_memory_store.py b/apps/adk-py/tests/unit/server/store/test_memory_store.py index b36f574f4..87d3be75a 100644 --- a/apps/adk-py/tests/unit/server/store/test_memory_store.py +++ b/apps/adk-py/tests/unit/server/store/test_memory_store.py @@ -323,16 +323,16 @@ async def test_search_empty_results(self): results = await inst.search("nothing") assert results == [] - # --- write --- + # --- create --- - async def test_write_returns_memory_id(self): + async def test_create_returns_memory_id(self): client = _mock_client() client.write.return_value = _write_result( memory=SimpleNamespace(id="new-id-42") ) inst = self._make(client) - memory_id = await inst.write("important fact") + memory_id = await inst.create("important fact") client.write.assert_awaited_once_with( "important fact", @@ -343,12 +343,12 @@ async def test_write_returns_memory_id(self): ) assert memory_id == "new-id-42" - async def test_write_passes_all_params(self): + async def test_create_passes_all_params(self): client = _mock_client() client.write.return_value = _write_result(memory=SimpleNamespace(id="x")) inst = self._make(client) - await inst.write( + await inst.create( "content", scope="project", weight=0.9, @@ -364,7 +364,7 @@ async def test_write_passes_all_params(self): project_id="proj-1", ) - async def test_write_returns_empty_string_when_curation_blocks(self): + async def test_create_returns_empty_string_when_curation_blocks(self): client = _mock_client() client.write.return_value = _write_result( memory=None, @@ -372,7 +372,7 @@ async def test_write_returns_empty_string_when_curation_blocks(self): ) inst = self._make(client) - memory_id = await inst.write("duplicate content") + memory_id = await inst.create("duplicate content") assert memory_id == "" # --- read --- @@ -480,12 +480,12 @@ async def test_search_delegates_to_instance(self): assert len(results) == 1 assert results[0].memory_id == "p1" - async def test_write_delegates_to_instance(self): + async def test_create_delegates_to_instance(self): client = _mock_client() client.write.return_value = _write_result(memory=SimpleNamespace(id="proxy-id")) _, proxy = self._make_store_and_proxy(client) - memory_id = await proxy.write("proxy write") + memory_id = await proxy.create("proxy create") assert memory_id == "proxy-id" async def test_read_delegates_to_instance(self): @@ -510,7 +510,7 @@ async def test_delete_delegates_to_instance(self): await proxy.delete("m-del") client.delete.assert_awaited_once_with("m-del") - async def test_curation_blocked_write_via_proxy(self): + async def test_curation_blocked_create_via_proxy(self): client = _mock_client() client.write.return_value = _write_result( memory=None, @@ -518,7 +518,7 @@ async def test_curation_blocked_write_via_proxy(self): ) _, proxy = self._make_store_and_proxy(client) - memory_id = await proxy.write("blocked content") + memory_id = await proxy.create("blocked content") assert memory_id == "" From 5c0336abff1e118cd957730ad80689f67b185382 Mon Sep 17 00:00:00 2001 From: rdwj Date: Tue, 28 Apr 2026 06:30:43 -0500 Subject: [PATCH 07/16] adk-py: Document MemoryStore protocol fields with backend-agnostic semantics The protocol intentionally leaves scope/weight/tags/project_id with backend-defined meaning. Spell that out in the docstrings so reviewers don't read MemoryHub's vocabulary as the protocol contract. Each entry includes the MemoryHub mapping as a worked example. Assisted-by: Claude Code (Opus 4.7) --- .../kagenti_adk/server/store/memory_store.py | 68 ++++++++++++++++--- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/apps/adk-py/src/kagenti_adk/server/store/memory_store.py b/apps/adk-py/src/kagenti_adk/server/store/memory_store.py index 764c5409a..7c7d1d62f 100644 --- a/apps/adk-py/src/kagenti_adk/server/store/memory_store.py +++ b/apps/adk-py/src/kagenti_adk/server/store/memory_store.py @@ -18,7 +18,7 @@ import abc from typing import Protocol -from pydantic import BaseModel +from pydantic import BaseModel, Field __all__ = [ "MemoryResult", @@ -28,13 +28,39 @@ class MemoryResult(BaseModel): - """A single memory returned from search or read.""" + """A single memory returned from search or read. - memory_id: str - content: str - scope: str - weight: float = 0.7 - relevance_score: float | None = None + Field semantics are intentionally backend-agnostic. Concrete backends + map their own concepts onto these fields; the MemoryHub backend's + mapping is documented inline as a worked example. + """ + + memory_id: str = Field( + description="Backend-assigned identifier for the memory." + ) + content: str = Field( + description="The memory's payload. May be a stub for search results." + ) + scope: str = Field( + description=( + "Visibility/governance domain. Backend-defined; in MemoryHub: " + "one of user/project/campaign/organizational/enterprise." + ) + ) + weight: float = Field( + default=0.7, + description=( + "Priority/curation signal in the range 0.0–1.0. Backends may use " + "it for ranking or ignore it." + ), + ) + relevance_score: float | None = Field( + default=None, + description=( + "Search relevance score returned by the backend; None for " + "non-search results." + ), + ) class MemoryStoreInstance(Protocol): @@ -43,6 +69,19 @@ class MemoryStoreInstance(Protocol): Each method maps to a standard memory lifecycle operation. Implementations should raise backend-specific errors for authorization failures or validation issues. + + Common keyword arguments share semantics across all methods: + + - ``scope``: Visibility/governance domain. Backend-defined; in + MemoryHub: one of user/project/campaign/organizational/enterprise. + - ``weight``: Priority/curation signal in the range 0.0–1.0. Backends + may use it for ranking or ignore it. + - ``tags``: Free-form tags for grouping/filtering. Backend-defined + semantics; in MemoryHub: "domains" attached to a memory. + - ``project_id``: Optional grouping within a memory store; + backend-defined semantics. In MemoryHub: a project with member-based + access control. NOT a tenancy boundary — tenancy is established by + the backend's auth credentials. """ async def search( @@ -52,7 +91,14 @@ async def search( scope: str | None = None, project_id: str | None = None, max_results: int = 10, - ) -> list[MemoryResult]: ... + ) -> list[MemoryResult]: + """Search for memories matching ``query``. + + ``scope`` and ``project_id`` filter results; their semantics are + backend-defined. See the class docstring for the cross-method + conventions. + """ + ... async def create( self, @@ -63,7 +109,11 @@ async def create( tags: list[str] | None = None, project_id: str | None = None, ) -> str: - """Create a new memory. Returns the new memory_id.""" + """Create a new memory. Returns the new memory_id. + + ``scope``, ``weight``, ``tags`` and ``project_id`` follow the + cross-method conventions documented on the class. + """ ... async def read(self, memory_id: str) -> MemoryResult | None: ... From 898a500f124cdc1f0ace20ca3ea156e3e6f575e5 Mon Sep 17 00:00:00 2001 From: rdwj Date: Tue, 28 Apr 2026 06:32:30 -0500 Subject: [PATCH 08/16] adk-py: Add MemoryHub A2A service extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors services/llm.py: Demand/Fulfillment/Params/Metadata/Spec/ Server/Client classes plus an env-var fallback used as the server-side default. Lets clients hand the agent its MemoryHub connection via A2A metadata instead of forcing the agent to read process env directly. No store wiring yet — that lands in the next commit. Assisted-by: Claude Code (Opus 4.7) --- .../a2a/extensions/services/__init__.py | 1 + .../a2a/extensions/services/memoryhub.py | 165 +++++++++++++ .../tests/unit/a2a/extensions/__init__.py | 2 + .../unit/a2a/extensions/services/__init__.py | 2 + .../a2a/extensions/services/test_memoryhub.py | 224 ++++++++++++++++++ 5 files changed, 394 insertions(+) create mode 100644 apps/adk-py/src/kagenti_adk/a2a/extensions/services/memoryhub.py create mode 100644 apps/adk-py/tests/unit/a2a/extensions/__init__.py create mode 100644 apps/adk-py/tests/unit/a2a/extensions/services/__init__.py create mode 100644 apps/adk-py/tests/unit/a2a/extensions/services/test_memoryhub.py diff --git a/apps/adk-py/src/kagenti_adk/a2a/extensions/services/__init__.py b/apps/adk-py/src/kagenti_adk/a2a/extensions/services/__init__.py index f5a107450..1feaad76b 100644 --- a/apps/adk-py/src/kagenti_adk/a2a/extensions/services/__init__.py +++ b/apps/adk-py/src/kagenti_adk/a2a/extensions/services/__init__.py @@ -7,4 +7,5 @@ from .form import * from .llm import * from .mcp import * +from .memoryhub import * from .platform import * diff --git a/apps/adk-py/src/kagenti_adk/a2a/extensions/services/memoryhub.py b/apps/adk-py/src/kagenti_adk/a2a/extensions/services/memoryhub.py new file mode 100644 index 000000000..db0bb4a0b --- /dev/null +++ b/apps/adk-py/src/kagenti_adk/a2a/extensions/services/memoryhub.py @@ -0,0 +1,165 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + + +from __future__ import annotations + +import os +from types import NoneType +from typing import TYPE_CHECKING, Any, Self + +import pydantic +from a2a.server.agent_execution.context import RequestContext +from a2a.types import Message as A2AMessage +from pydantic import SecretStr +from typing_extensions import override + +from kagenti_adk.a2a.extensions.base import ( + DEFAULT_DEMAND_NAME, + BaseExtensionClient, + BaseExtensionServer, + BaseExtensionSpec, +) +from kagenti_adk.util.pydantic import REVEAL_SECRETS, SecureBaseModel, redact_str + +__all__ = [ + "MemoryHubDemand", + "MemoryHubExtensionClient", + "MemoryHubExtensionMetadata", + "MemoryHubExtensionParams", + "MemoryHubExtensionServer", + "MemoryHubExtensionSpec", + "MemoryHubFulfillment", +] + +if TYPE_CHECKING: + from kagenti_adk.server.context import RunContext + + +class MemoryHubFulfillment(SecureBaseModel): + """Connection details the client provides for a MemoryHub instance.""" + + url: str + """ + Base URL of the MemoryHub MCP endpoint, e.g. + ``https://memory-hub-mcp.example.com/mcp/``. + """ + + api_key: SecretStr | None = None + """ + Static API key for the dev/testing path. Mutually exclusive with the + OAuth fields below. + """ + + auth_url: str | None = None + """ + OAuth 2.1 token endpoint. Required together with ``client_id`` and + ``client_secret`` for the OAuth path. + """ + + client_id: str | None = None + """ + OAuth 2.1 client identifier. + """ + + client_secret: SecretStr | None = None + """ + OAuth 2.1 client secret. + """ + + @pydantic.field_serializer("url") + def _redact_url(self, v: str, info) -> str: + return redact_str(v, info) + + +class MemoryHubDemand(pydantic.BaseModel): + """A request from the agent for a MemoryHub fulfillment.""" + + description: str | None = None + """ + Short description of how the memory store will be used. Intended to be + shown in the UI alongside a connection picker. + """ + + +class MemoryHubExtensionParams(pydantic.BaseModel): + memoryhub_demands: dict[str, MemoryHubDemand] + """MemoryHub connections that the agent requires the client to provide.""" + + +class MemoryHubExtensionMetadata(pydantic.BaseModel): + memoryhub_fulfillments: dict[str, MemoryHubFulfillment] = {} + """Connection details corresponding to the agent's demands.""" + + +class MemoryHubExtensionSpec(BaseExtensionSpec[MemoryHubExtensionParams, MemoryHubExtensionMetadata]): + URI: str = "https://a2a-extensions.adk.kagenti.dev/services/memoryhub/v1" + + @classmethod + def single_demand( + cls, + name: str = DEFAULT_DEMAND_NAME, + description: str | None = None, + default: MemoryHubFulfillment | None = None, + ) -> Self: + return cls( + params=MemoryHubExtensionParams( + memoryhub_demands={name: MemoryHubDemand(description=description)} + ), + default=( + MemoryHubExtensionMetadata(memoryhub_fulfillments={name: default}) if default else None + ), + ) + + +class MemoryHubExtensionServer(BaseExtensionServer[MemoryHubExtensionSpec, MemoryHubExtensionMetadata]): + @override + def handle_incoming_message(self, message: A2AMessage, run_context: RunContext, request_context: RequestContext): + super().handle_incoming_message(message, run_context, request_context) + + if not self._metadata_from_client or not self._metadata_from_client.memoryhub_fulfillments: + fulfillment = _memoryhub_fulfillment_from_env() + if fulfillment: + self._metadata_from_client = MemoryHubExtensionMetadata( + memoryhub_fulfillments={"default": fulfillment} + ) + + +class MemoryHubExtensionClient(BaseExtensionClient[MemoryHubExtensionSpec, NoneType]): + def fulfillment_metadata( + self, *, memoryhub_fulfillments: dict[str, MemoryHubFulfillment] + ) -> dict[str, Any]: + return { + self.spec.URI: MemoryHubExtensionMetadata( + memoryhub_fulfillments=memoryhub_fulfillments + ).model_dump(mode="json", context={REVEAL_SECRETS: True}) + } + + +def _memoryhub_fulfillment_from_env() -> MemoryHubFulfillment | None: + """Build a default MemoryHub fulfillment from environment variables. + + Reads ``MEMORYHUB_URL`` (required), and either ``MEMORYHUB_API_KEY`` + (dev path) or ``MEMORYHUB_AUTH_URL`` + ``MEMORYHUB_CLIENT_ID`` + + ``MEMORYHUB_CLIENT_SECRET`` (OAuth 2.1 path). Returns None if no URL + is set or no usable credential is available. + """ + url = os.environ.get("MEMORYHUB_URL") + if not url: + return None + + api_key = os.environ.get("MEMORYHUB_API_KEY") + auth_url = os.environ.get("MEMORYHUB_AUTH_URL") + client_id = os.environ.get("MEMORYHUB_CLIENT_ID") + client_secret = os.environ.get("MEMORYHUB_CLIENT_SECRET") + + if api_key: + return MemoryHubFulfillment(url=url, api_key=SecretStr(api_key)) + if auth_url and client_id and client_secret: + return MemoryHubFulfillment( + url=url, + auth_url=auth_url, + client_id=client_id, + client_secret=SecretStr(client_secret), + ) + return None diff --git a/apps/adk-py/tests/unit/a2a/extensions/__init__.py b/apps/adk-py/tests/unit/a2a/extensions/__init__.py new file mode 100644 index 000000000..7abe8ca66 --- /dev/null +++ b/apps/adk-py/tests/unit/a2a/extensions/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 diff --git a/apps/adk-py/tests/unit/a2a/extensions/services/__init__.py b/apps/adk-py/tests/unit/a2a/extensions/services/__init__.py new file mode 100644 index 000000000..7abe8ca66 --- /dev/null +++ b/apps/adk-py/tests/unit/a2a/extensions/services/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 diff --git a/apps/adk-py/tests/unit/a2a/extensions/services/test_memoryhub.py b/apps/adk-py/tests/unit/a2a/extensions/services/test_memoryhub.py new file mode 100644 index 000000000..d73e80dda --- /dev/null +++ b/apps/adk-py/tests/unit/a2a/extensions/services/test_memoryhub.py @@ -0,0 +1,224 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import os +from unittest.mock import patch + +import pytest +from pydantic import SecretStr + +from kagenti_adk.a2a.extensions import ( + MemoryHubExtensionClient, + MemoryHubExtensionMetadata, + MemoryHubExtensionParams, + MemoryHubExtensionSpec, + MemoryHubFulfillment, +) +from kagenti_adk.a2a.extensions.services.memoryhub import _memoryhub_fulfillment_from_env +from kagenti_adk.util.pydantic import REVEAL_SECRETS + +pytestmark = pytest.mark.unit + + +@pytest.fixture(autouse=True) +def _clean_env(): + """Ensure no MEMORYHUB env vars leak between tests.""" + env_vars = [ + "MEMORYHUB_URL", + "MEMORYHUB_API_KEY", + "MEMORYHUB_AUTH_URL", + "MEMORYHUB_CLIENT_ID", + "MEMORYHUB_CLIENT_SECRET", + ] + with patch.dict(os.environ, {}, clear=False): + for var in env_vars: + os.environ.pop(var, None) + yield + + +# --------------------------------------------------------------------------- +# Spec construction / round-trip +# --------------------------------------------------------------------------- + + +class TestMemoryHubExtensionSpec: + def test_uri_is_versioned(self): + spec = MemoryHubExtensionSpec( + params=MemoryHubExtensionParams(memoryhub_demands={}) + ) + assert spec.URI == "https://a2a-extensions.adk.kagenti.dev/services/memoryhub/v1" + + def test_single_demand_default_name(self): + spec = MemoryHubExtensionSpec.single_demand() + assert "default" in spec.params.memoryhub_demands + assert spec.params.memoryhub_demands["default"].description is None + + def test_single_demand_custom_name_and_description(self): + spec = MemoryHubExtensionSpec.single_demand( + name="primary", description="cross-session knowledge" + ) + assert "primary" in spec.params.memoryhub_demands + assert spec.params.memoryhub_demands["primary"].description == "cross-session knowledge" + + def test_single_demand_with_default_fulfillment(self): + default = MemoryHubFulfillment(url="http://hub", api_key=SecretStr("k")) + spec = MemoryHubExtensionSpec.single_demand(default=default) + assert spec.default is not None + assert spec.default.memoryhub_fulfillments["default"].url == "http://hub" + + def test_to_agent_card_extensions_round_trips(self): + spec = MemoryHubExtensionSpec.single_demand(description="test") + extensions = spec.to_agent_card_extensions() + assert len(extensions) == 1 + assert extensions[0].uri == MemoryHubExtensionSpec.URI + + +# --------------------------------------------------------------------------- +# Fulfillment serialization (redaction) +# --------------------------------------------------------------------------- + + +class TestMemoryHubFulfillmentSerialization: + def test_api_key_redacted_in_default_dump(self): + f = MemoryHubFulfillment(url="http://hub", api_key=SecretStr("super-secret")) + dumped = f.model_dump() + # SecretStr's repr keeps the value behind a wrapper; ensure it isn't leaked as a plain str. + assert dumped["api_key"] != "super-secret" + + def test_api_key_revealed_with_context(self): + f = MemoryHubFulfillment(url="http://hub", api_key=SecretStr("super-secret")) + dumped = f.model_dump(context={REVEAL_SECRETS: True}) + assert dumped["api_key"] == "super-secret" + + def test_client_secret_redacted_in_default_dump(self): + f = MemoryHubFulfillment( + url="http://hub", + auth_url="http://auth", + client_id="cid", + client_secret=SecretStr("csec"), + ) + dumped = f.model_dump() + assert dumped["client_secret"] != "csec" + + def test_client_secret_revealed_with_context(self): + f = MemoryHubFulfillment( + url="http://hub", + auth_url="http://auth", + client_id="cid", + client_secret=SecretStr("csec"), + ) + dumped = f.model_dump(context={REVEAL_SECRETS: True}) + assert dumped["client_secret"] == "csec" + + +# --------------------------------------------------------------------------- +# Client metadata production +# --------------------------------------------------------------------------- + + +class TestMemoryHubExtensionClient: + def test_fulfillment_metadata_reveals_secrets(self): + spec = MemoryHubExtensionSpec.single_demand() + client = MemoryHubExtensionClient(spec) + fulfillment = MemoryHubFulfillment(url="http://hub", api_key=SecretStr("the-key")) + + metadata = client.fulfillment_metadata(memoryhub_fulfillments={"default": fulfillment}) + + assert spec.URI in metadata + payload = metadata[spec.URI] + assert payload["memoryhub_fulfillments"]["default"]["api_key"] == "the-key" + + def test_fulfillment_metadata_round_trip(self): + spec = MemoryHubExtensionSpec.single_demand() + client = MemoryHubExtensionClient(spec) + fulfillment = MemoryHubFulfillment(url="http://hub", api_key=SecretStr("the-key")) + + metadata = client.fulfillment_metadata(memoryhub_fulfillments={"default": fulfillment}) + # Server side parses MemoryHubExtensionMetadata back from this payload. + parsed = MemoryHubExtensionMetadata.model_validate(metadata[spec.URI]) + assert "default" in parsed.memoryhub_fulfillments + assert parsed.memoryhub_fulfillments["default"].url == "http://hub" + + +# --------------------------------------------------------------------------- +# Env-var fallback +# --------------------------------------------------------------------------- + + +class TestMemoryHubFulfillmentFromEnv: + def test_returns_none_when_no_env_vars(self): + assert _memoryhub_fulfillment_from_env() is None + + def test_returns_none_when_url_only(self): + os.environ["MEMORYHUB_URL"] = "http://hub" + assert _memoryhub_fulfillment_from_env() is None + + def test_api_key_path(self): + os.environ["MEMORYHUB_URL"] = "http://hub" + os.environ["MEMORYHUB_API_KEY"] = "the-key" + + result = _memoryhub_fulfillment_from_env() + assert result is not None + assert result.url == "http://hub" + assert result.api_key.get_secret_value() == "the-key" + assert result.client_secret is None + + def test_oauth_path(self): + os.environ["MEMORYHUB_URL"] = "http://hub" + os.environ["MEMORYHUB_AUTH_URL"] = "http://auth" + os.environ["MEMORYHUB_CLIENT_ID"] = "cid" + os.environ["MEMORYHUB_CLIENT_SECRET"] = "csec" + + result = _memoryhub_fulfillment_from_env() + assert result is not None + assert result.url == "http://hub" + assert result.auth_url == "http://auth" + assert result.client_id == "cid" + assert result.client_secret.get_secret_value() == "csec" + + def test_api_key_takes_precedence_over_oauth(self): + os.environ["MEMORYHUB_URL"] = "http://hub" + os.environ["MEMORYHUB_API_KEY"] = "the-key" + os.environ["MEMORYHUB_AUTH_URL"] = "http://auth" + os.environ["MEMORYHUB_CLIENT_ID"] = "cid" + os.environ["MEMORYHUB_CLIENT_SECRET"] = "csec" + + result = _memoryhub_fulfillment_from_env() + assert result is not None + assert result.api_key.get_secret_value() == "the-key" + assert result.auth_url is None + assert result.client_id is None + + def test_returns_none_when_oauth_partial(self): + os.environ["MEMORYHUB_URL"] = "http://hub" + os.environ["MEMORYHUB_AUTH_URL"] = "http://auth" + # missing client_id and client_secret + assert _memoryhub_fulfillment_from_env() is None + + +# --------------------------------------------------------------------------- +# Params validation +# --------------------------------------------------------------------------- + + +class TestMemoryHubExtensionParams: + def test_empty_demands_allowed(self): + params = MemoryHubExtensionParams(memoryhub_demands={}) + assert params.memoryhub_demands == {} + + def test_multiple_demands(self): + params = MemoryHubExtensionParams( + memoryhub_demands={ + "primary": _demand("primary store"), + "audit": _demand("audit log"), + } + ) + assert set(params.memoryhub_demands) == {"primary", "audit"} + + +def _demand(description: str): + from kagenti_adk.a2a.extensions.services.memoryhub import MemoryHubDemand + + return MemoryHubDemand(description=description) From 6158798a1de866b4dea72ea1152f1f001d3db275 Mon Sep 17 00:00:00 2001 From: rdwj Date: Tue, 28 Apr 2026 06:34:47 -0500 Subject: [PATCH 09/16] adk-py: Wire MemoryHub store through the extension; remove from_env auto-loader Drops MemoryHubMemoryStore, _MemoryProxy and create_memory_dependency. MemoryHubExtensionServer now owns the MemoryHubClient lifecycle via its A2A lifespan() and exposes per-context MemoryHubMemoryStoreInstance via .store(context_id). Async resolution is now handled by the awaitable Depends fix in commit 1, so the proxy workaround is no longer needed. Closes the dependency on _MemoryProxy that #229 introduced. Assisted-by: Claude Code (Opus 4.7) --- .../server/store/memoryhub_memory_store.py | 240 ++++------ .../unit/server/store/test_memory_store.py | 413 +++++------------- 2 files changed, 201 insertions(+), 452 deletions(-) diff --git a/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py index ce8166fd7..f6edea2ce 100644 --- a/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py +++ b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py @@ -6,37 +6,39 @@ Wraps the ``memoryhub`` Python SDK to provide governed, cross-session memory to ADK agents via the MemoryStore protocol. Requires the ``memoryhub`` extra: - pip install kagenti-adk[memoryhub] + uv add kagenti-adk[memoryhub] -Authentication is configured via environment variables: - - OAuth 2.1 (recommended): - MEMORYHUB_URL, MEMORYHUB_AUTH_URL, MEMORYHUB_CLIENT_ID, MEMORYHUB_CLIENT_SECRET - - API key (dev/testing): - MEMORYHUB_URL, MEMORYHUB_API_KEY +The connection is supplied to agents via the MemoryHub A2A service extension +(``services.memoryhub.MemoryHubExtensionSpec``); the +:class:`MemoryHubExtensionServer` opens and closes the underlying client as +part of its ``lifespan`` and exposes per-context store instances via +:meth:`MemoryHubExtensionServer.store`. """ from __future__ import annotations import logging -import os -from typing import TYPE_CHECKING, Any +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING -from kagenti_adk.server.store.memory_store import MemoryResult, MemoryStore, MemoryStoreInstance +from memoryhub.client import MemoryHubClient +from memoryhub.exceptions import NotFoundError +from typing_extensions import override -if TYPE_CHECKING: - from a2a.types import Message - from memoryhub.client import MemoryHubClient +from kagenti_adk.a2a.extensions.services.memoryhub import ( + MemoryHubExtensionServer as _BaseMemoryHubExtensionServer, +) +from kagenti_adk.server.store.memory_store import MemoryResult, MemoryStoreInstance - from kagenti_adk.server.context import RunContext +if TYPE_CHECKING: + pass logger = logging.getLogger(__name__) __all__ = [ - "MemoryHubMemoryStore", + "MemoryHubExtensionServer", "MemoryHubMemoryStoreInstance", - "create_memory_dependency", ] @@ -95,8 +97,6 @@ async def create( return result.memory.id async def read(self, memory_id: str) -> MemoryResult | None: - from memoryhub.exceptions import NotFoundError - try: m = await self._client.read(memory_id) except NotFoundError: @@ -115,149 +115,75 @@ async def delete(self, memory_id: str) -> None: await self._client.delete(memory_id) -class MemoryHubMemoryStore(MemoryStore): - """Factory for MemoryHub-backed memory store instances. +class MemoryHubExtensionServer(_BaseMemoryHubExtensionServer): + """Server-side MemoryHub extension that owns the MemoryHub client lifecycle. - Holds connection configuration and lazily creates the MemoryHubClient - on first use. The client is shared across all instances (contexts) - because it manages its own auth token lifecycle. + Subclasses the protocol-only base with a ``lifespan()`` that opens the + underlying ``memoryhub.client.MemoryHubClient`` from the active + :class:`MemoryHubFulfillment` and closes it on exit. Use + :meth:`store` to obtain a :class:`MemoryHubMemoryStoreInstance` bound + to the request's context. """ - def __init__( - self, - *, - url: str | None = None, - auth_url: str | None = None, - client_id: str | None = None, - client_secret: str | None = None, - api_key: str | None = None, - ) -> None: - self._url = url - self._auth_url = auth_url - self._client_id = client_id - self._client_secret = client_secret - self._api_key = api_key - self._client: MemoryHubClient | None = None - - @classmethod - def from_env(cls) -> MemoryHubMemoryStore: - """Create from MEMORYHUB_* environment variables.""" - return cls( - url=os.environ.get("MEMORYHUB_URL"), - auth_url=os.environ.get("MEMORYHUB_AUTH_URL"), - client_id=os.environ.get("MEMORYHUB_CLIENT_ID"), - client_secret=os.environ.get("MEMORYHUB_CLIENT_SECRET"), - api_key=os.environ.get("MEMORYHUB_API_KEY"), - ) - - async def _get_client(self) -> MemoryHubClient: - if self._client is None: - from memoryhub.client import MemoryHubClient - - if self._api_key: - self._client = MemoryHubClient( - url=self._url, - api_key=self._api_key, - ) - else: - self._client = MemoryHubClient( - url=self._url, - auth_url=self._auth_url, - client_id=self._client_id, - client_secret=self._client_secret, - ) - await self._client.__aenter__() - logger.info("MemoryHub client connected to %s", self._url) - return self._client + _client: MemoryHubClient | None = None + + @asynccontextmanager + @override + async def lifespan(self) -> AsyncGenerator[None]: + fulfillment = self._resolve_fulfillment() + if fulfillment is None: + # Extension was not fulfilled by the client and no env fallback; + # leave _client unset so accidental store() use raises clearly. + yield + return + + if fulfillment.api_key is not None: + client = MemoryHubClient( + url=fulfillment.url, + api_key=fulfillment.api_key.get_secret_value(), + ) + else: + client = MemoryHubClient( + url=fulfillment.url, + auth_url=fulfillment.auth_url, + client_id=fulfillment.client_id, + client_secret=( + fulfillment.client_secret.get_secret_value() + if fulfillment.client_secret is not None + else None + ), + ) - async def close(self) -> None: - """Close the underlying MemoryHub client session.""" - if self._client is not None: - await self._client.__aexit__(None, None, None) + await client.__aenter__() + self._client = client + logger.info("MemoryHub client connected to %s", fulfillment.url) + try: + yield + finally: + await client.__aexit__(None, None, None) self._client = None - async def create(self, context_id: str) -> MemoryStoreInstance: - client = await self._get_client() - return MemoryHubMemoryStoreInstance(context_id=context_id, client=client) - - -class _MemoryProxy: - """Lazy-initializing proxy that resolves the MemoryStoreInstance on first use. - - The ADK's Depends framework calls the dependency callable synchronously and - yields the return value. Since MemoryStore.create() is async, we can't call - it during dependency resolution. Instead, we return this proxy which lazily - awaits create() on the first method call. - """ - - def __init__(self, store: MemoryHubMemoryStore, context_id: str) -> None: - self._store = store - self._context_id = context_id - self._instance: MemoryHubMemoryStoreInstance | None = None - - async def _resolve(self) -> MemoryHubMemoryStoreInstance: - if self._instance is None: - self._instance = await self._store.create(self._context_id) - return self._instance - - async def search( - self, - query: str, - *, - scope: str | None = None, - project_id: str | None = None, - max_results: int = 10, - ) -> list[MemoryResult]: - inst = await self._resolve() - return await inst.search(query, scope=scope, project_id=project_id, max_results=max_results) - - async def create( - self, - content: str, - *, - scope: str = "user", - weight: float = 0.7, - tags: list[str] | None = None, - project_id: str | None = None, - ) -> str: - inst = await self._resolve() - return await inst.create(content, scope=scope, weight=weight, tags=tags, project_id=project_id) - - async def read(self, memory_id: str) -> MemoryResult | None: - inst = await self._resolve() - return await inst.read(memory_id) - - async def update(self, memory_id: str, content: str) -> None: - inst = await self._resolve() - return await inst.update(memory_id, content) - - async def delete(self, memory_id: str) -> None: - inst = await self._resolve() - return await inst.delete(memory_id) - - -def create_memory_dependency(store: MemoryHubMemoryStore): - """Create a DI-compatible dependency provider for the ADK Depends pattern. - - Returns a synchronous callable (required by ADK's Depends) that produces - a lazy-initializing proxy. The proxy resolves the MemoryStoreInstance - on first async method call. - - Usage:: - - memory_store = MemoryHubMemoryStore.from_env() - memory_dep = create_memory_dependency(memory_store) + def store(self, context_id: str) -> MemoryHubMemoryStoreInstance: + """Return a per-context store instance. - @server.agent() - async def my_agent( - input: Message, - context: RunContext, - memory: Annotated[MemoryHubMemoryStoreInstance, Depends(memory_dep)], - ): - results = await memory.search("user preferences") - """ - - def provider(message: Message, context: RunContext, request_context: Any) -> _MemoryProxy: - return _MemoryProxy(store, context.context_id) + Must be called inside the extension's ``lifespan`` window — i.e. + from agent code that depends on the extension via ``Annotated[..., + MemoryHubExtensionSpec.single_demand()]``. + """ + if self._client is None: + raise RuntimeError( + "MemoryHubExtensionServer.store() called without an active client. " + "Either fulfill the extension via A2A metadata or set MEMORYHUB_URL " + "and credentials in the environment." + ) + return MemoryHubMemoryStoreInstance(context_id=context_id, client=self._client) - return provider + def _resolve_fulfillment(self): + # data() falls back to spec.default if the client did not provide metadata. + try: + data = self.data + except AttributeError: + return None + if data is None or not data.memoryhub_fulfillments: + return None + return next(iter(data.memoryhub_fulfillments.values())) diff --git a/apps/adk-py/tests/unit/server/store/test_memory_store.py b/apps/adk-py/tests/unit/server/store/test_memory_store.py index 87d3be75a..f8fdc7595 100644 --- a/apps/adk-py/tests/unit/server/store/test_memory_store.py +++ b/apps/adk-py/tests/unit/server/store/test_memory_store.py @@ -3,72 +3,36 @@ """Unit tests for MemoryStore protocol and MemoryHub implementation. -The memoryhub SDK is an optional dependency, so we patch it at the module -level before importing the implementation. All tests are async-compatible -via pytest-asyncio's asyncio_mode = "auto" (set in pyproject.toml). +The ``memoryhub`` SDK is now a real dev dependency, so we drive the +underlying ``MemoryHubClient`` calls via mocks rather than stubbing the +whole module tree. The MemoryHubExtensionServer's ``lifespan()`` is +exercised by patching ``MemoryHubClient`` to return an ``AsyncMock``. """ from __future__ import annotations -import sys -from types import ModuleType, SimpleNamespace +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest +from pydantic import SecretStr +from kagenti_adk.a2a.extensions.services.memoryhub import ( + MemoryHubExtensionMetadata, + MemoryHubExtensionSpec, + MemoryHubFulfillment, +) from kagenti_adk.server.store.memory_store import MemoryResult - -pytestmark = pytest.mark.unit - - -# --------------------------------------------------------------------------- -# Helpers — build a fake memoryhub module tree so the implementation can be -# imported and used without the real SDK installed. -# --------------------------------------------------------------------------- - - -def _make_memoryhub_stubs() -> tuple[ModuleType, MagicMock]: - """Return (memoryhub package stub, MemoryHubClient class mock).""" - client_mock = MagicMock() - - memoryhub = ModuleType("memoryhub") - memoryhub_client = ModuleType("memoryhub.client") - memoryhub_exceptions = ModuleType("memoryhub.exceptions") - - # NotFoundError used in the read() implementation - class NotFoundError(Exception): - pass - - memoryhub_exceptions.NotFoundError = NotFoundError - memoryhub_client.MemoryHubClient = client_mock - - memoryhub.client = memoryhub_client - memoryhub.exceptions = memoryhub_exceptions - - return memoryhub, client_mock, NotFoundError - - -def _install_stubs(memoryhub, client_mod, exc_mod): - sys.modules.setdefault("memoryhub", memoryhub) - sys.modules.setdefault("memoryhub.client", client_mod) - sys.modules.setdefault("memoryhub.exceptions", exc_mod) - - -# Build stubs once for the module lifetime. -_memoryhub_stub, _ClientClass, _NotFoundError = _make_memoryhub_stubs() -_install_stubs(_memoryhub_stub, _memoryhub_stub.client, _memoryhub_stub.exceptions) - -# Now it is safe to import the implementation. -from kagenti_adk.server.store.memoryhub_memory_store import ( # noqa: E402, I001 - MemoryHubMemoryStore, +from kagenti_adk.server.store.memoryhub_memory_store import ( + MemoryHubExtensionServer, MemoryHubMemoryStoreInstance, - _MemoryProxy, - create_memory_dependency, ) +pytestmark = pytest.mark.unit + # --------------------------------------------------------------------------- -# Factories +# Helpers # --------------------------------------------------------------------------- @@ -108,6 +72,10 @@ def _write_result(memory=None, curation=None) -> SimpleNamespace: return SimpleNamespace(memory=memory, curation=curation) +class _NotFoundError(Exception): + """Stand-in matching ``memoryhub.exceptions.NotFoundError``.""" + + # --------------------------------------------------------------------------- # MemoryResult model # --------------------------------------------------------------------------- @@ -134,123 +102,6 @@ def test_explicit_weight_and_relevance(self): assert r.relevance_score == 0.85 -# --------------------------------------------------------------------------- -# MemoryHubMemoryStore construction and from_env -# --------------------------------------------------------------------------- - - -class TestMemoryHubMemoryStore: - def test_direct_construction_stores_params(self): - store = MemoryHubMemoryStore(url="http://hub", api_key="key-123") - assert store._url == "http://hub" - assert store._api_key == "key-123" - assert store._client is None - - def test_from_env_reads_api_key(self, monkeypatch): - monkeypatch.setenv("MEMORYHUB_URL", "http://hub.example.com") - monkeypatch.setenv("MEMORYHUB_API_KEY", "secret-key") - monkeypatch.delenv("MEMORYHUB_AUTH_URL", raising=False) - monkeypatch.delenv("MEMORYHUB_CLIENT_ID", raising=False) - monkeypatch.delenv("MEMORYHUB_CLIENT_SECRET", raising=False) - - store = MemoryHubMemoryStore.from_env() - assert store._url == "http://hub.example.com" - assert store._api_key == "secret-key" - assert store._auth_url is None - - def test_from_env_reads_oauth_vars(self, monkeypatch): - monkeypatch.setenv("MEMORYHUB_URL", "http://hub.example.com") - monkeypatch.setenv("MEMORYHUB_AUTH_URL", "http://auth.example.com") - monkeypatch.setenv("MEMORYHUB_CLIENT_ID", "client-id") - monkeypatch.setenv("MEMORYHUB_CLIENT_SECRET", "client-secret") - monkeypatch.delenv("MEMORYHUB_API_KEY", raising=False) - - store = MemoryHubMemoryStore.from_env() - assert store._auth_url == "http://auth.example.com" - assert store._client_id == "client-id" - assert store._client_secret == "client-secret" - assert store._api_key is None - - def test_from_env_missing_vars_yields_none(self, monkeypatch): - for var in ("MEMORYHUB_URL", "MEMORYHUB_API_KEY", "MEMORYHUB_AUTH_URL", - "MEMORYHUB_CLIENT_ID", "MEMORYHUB_CLIENT_SECRET"): - monkeypatch.delenv(var, raising=False) - - store = MemoryHubMemoryStore.from_env() - assert store._url is None - assert store._api_key is None - - async def test_create_returns_instance(self): - store = MemoryHubMemoryStore(url="http://hub", api_key="k") - client = _mock_client() - store._client = client - - inst = await store.create("ctx-1") - assert isinstance(inst, MemoryHubMemoryStoreInstance) - assert inst._context_id == "ctx-1" - assert inst._client is client - - async def test_get_client_uses_api_key_path(self): - store = MemoryHubMemoryStore(url="http://hub", api_key="my-key") - fake_client = _mock_client() - _ClientClass.return_value = fake_client - - with patch("memoryhub.client.MemoryHubClient", _ClientClass): - await store._get_client() - - _ClientClass.assert_called_once_with(url="http://hub", api_key="my-key") - assert store._client is fake_client - - async def test_get_client_uses_oauth_path(self): - store = MemoryHubMemoryStore( - url="http://hub", - auth_url="http://auth", - client_id="cid", - client_secret="csec", - ) - fake_client = _mock_client() - _ClientClass.reset_mock() - _ClientClass.return_value = fake_client - - with patch("memoryhub.client.MemoryHubClient", _ClientClass): - await store._get_client() - - _ClientClass.assert_called_once_with( - url="http://hub", - auth_url="http://auth", - client_id="cid", - client_secret="csec", - ) - assert store._client is fake_client - - async def test_close_cleans_up_client(self): - store = MemoryHubMemoryStore(url="http://hub", api_key="k") - fake_client = _mock_client() - store._client = fake_client - - await store.close() - - fake_client.__aexit__.assert_awaited_once_with(None, None, None) - assert store._client is None - - async def test_close_noop_when_no_client(self): - store = MemoryHubMemoryStore(url="http://hub", api_key="k") - assert store._client is None - await store.close() # should not raise - assert store._client is None - - async def test_get_client_is_cached(self): - store = MemoryHubMemoryStore(url="http://hub", api_key="k") - fake_client = _mock_client() - store._client = fake_client - - _ClientClass.reset_mock() - client = await store._get_client() - assert client is fake_client - # Pre-populated _client — constructor must not be called again. - _ClientClass.assert_not_called() - - # --------------------------------------------------------------------------- # MemoryHubMemoryStoreInstance operations # --------------------------------------------------------------------------- @@ -279,10 +130,12 @@ async def test_search_maps_results(self): "test query", scope=None, project_id=None, max_results=10 ) assert len(results) == 2 - assert results[0] == MemoryResult(memory_id="m1", content="alpha", scope="user", - weight=0.8, relevance_score=0.9) - assert results[1] == MemoryResult(memory_id="m2", content="beta", scope="project", - weight=0.6, relevance_score=0.7) + assert results[0] == MemoryResult( + memory_id="m1", content="alpha", scope="user", weight=0.8, relevance_score=0.9 + ) + assert results[1] == MemoryResult( + memory_id="m2", content="beta", scope="project", weight=0.6, relevance_score=0.7 + ) async def test_search_passes_optional_kwargs(self): client = _mock_client() @@ -327,9 +180,7 @@ async def test_search_empty_results(self): async def test_create_returns_memory_id(self): client = _mock_client() - client.write.return_value = _write_result( - memory=SimpleNamespace(id="new-id-42") - ) + client.write.return_value = _write_result(memory=SimpleNamespace(id="new-id-42")) inst = self._make(client) memory_id = await inst.create("important fact") @@ -392,12 +243,17 @@ async def test_read_returns_memory_result(self): ) async def test_read_returns_none_for_not_found(self): - client = _mock_client() - client.read.side_effect = _NotFoundError("not found") - inst = self._make(client) + # Patch the NotFoundError in the implementation module to our local class + # so the except clause matches our raised exception. + from kagenti_adk.server.store import memoryhub_memory_store as impl - result = await inst.read("missing-id") - assert result is None + with patch.object(impl, "NotFoundError", _NotFoundError): + client = _mock_client() + client.read.side_effect = _NotFoundError("not found") + inst = self._make(client) + + result = await inst.read("missing-id") + assert result is None async def test_read_empty_content_defaults_to_empty_string(self): client = _mock_client() @@ -445,136 +301,103 @@ async def test_delete_returns_none(self): # --------------------------------------------------------------------------- -# _MemoryProxy — lazy initialization and method delegation +# MemoryHubExtensionServer lifecycle # --------------------------------------------------------------------------- -class TestMemoryProxy: - def _make_store_and_proxy(self, client=None) -> tuple[MemoryHubMemoryStore, _MemoryProxy]: - store = MemoryHubMemoryStore(url="http://hub", api_key="k") - store._client = client or _mock_client() - proxy = _MemoryProxy(store=store, context_id="ctx-proxy") - return store, proxy - - async def test_instance_is_none_before_first_call(self): - _, proxy = self._make_store_and_proxy() - assert proxy._instance is None - - async def test_resolve_creates_instance_once(self): - _, proxy = self._make_store_and_proxy() - - inst1 = await proxy._resolve() - inst2 = await proxy._resolve() - - assert inst1 is inst2 - assert isinstance(inst1, MemoryHubMemoryStoreInstance) - - async def test_search_delegates_to_instance(self): - client = _mock_client() - client.search.return_value = _search_result( - _memory_obj(id="p1", content="proxy content", scope="user") +class TestMemoryHubExtensionServerLifespan: + def _server_with_default(self, fulfillment: MemoryHubFulfillment) -> MemoryHubExtensionServer: + spec = MemoryHubExtensionSpec.single_demand(default=fulfillment) + server = MemoryHubExtensionServer(spec) + # Activate by simulating a parsed metadata payload. + server._metadata_from_client = MemoryHubExtensionMetadata( + memoryhub_fulfillments={"default": fulfillment} ) - _, proxy = self._make_store_and_proxy(client) - - results = await proxy.search("via proxy") - assert len(results) == 1 - assert results[0].memory_id == "p1" - - async def test_create_delegates_to_instance(self): - client = _mock_client() - client.write.return_value = _write_result(memory=SimpleNamespace(id="proxy-id")) - _, proxy = self._make_store_and_proxy(client) - - memory_id = await proxy.create("proxy create") - assert memory_id == "proxy-id" + return server - async def test_read_delegates_to_instance(self): - client = _mock_client() - client.read.return_value = _memory_obj(id="r1", content="c", scope="user") - _, proxy = self._make_store_and_proxy(client) + async def test_lifespan_opens_and_closes_api_key_client(self): + fulfillment = MemoryHubFulfillment( + url="http://hub", api_key=SecretStr("the-key") + ) + server = self._server_with_default(fulfillment) + fake_client = _mock_client() - result = await proxy.read("r1") - assert result.memory_id == "r1" + with patch( + "kagenti_adk.server.store.memoryhub_memory_store.MemoryHubClient", + return_value=fake_client, + ) as ClientCls: + async with server.lifespan(): + # Inside the lifespan, store() should hand back a usable instance. + inst = server.store("ctx-1") + assert isinstance(inst, MemoryHubMemoryStoreInstance) + assert inst._context_id == "ctx-1" + assert inst._client is fake_client + + ClientCls.assert_called_once_with(url="http://hub", api_key="the-key") + fake_client.__aenter__.assert_awaited_once() + fake_client.__aexit__.assert_awaited_once_with(None, None, None) + + # After lifespan exit, store() raises. + with pytest.raises(RuntimeError): + server.store("ctx-1") + + async def test_lifespan_uses_oauth_path(self): + fulfillment = MemoryHubFulfillment( + url="http://hub", + auth_url="http://auth", + client_id="cid", + client_secret=SecretStr("csec"), + ) + server = self._server_with_default(fulfillment) + fake_client = _mock_client() - async def test_update_delegates_to_instance(self): - client = _mock_client() - _, proxy = self._make_store_and_proxy(client) + with patch( + "kagenti_adk.server.store.memoryhub_memory_store.MemoryHubClient", + return_value=fake_client, + ) as ClientCls: + async with server.lifespan(): + pass - await proxy.update("m-1", "updated") - client.update.assert_awaited_once_with("m-1", content="updated") + ClientCls.assert_called_once_with( + url="http://hub", + auth_url="http://auth", + client_id="cid", + client_secret="csec", + ) - async def test_delete_delegates_to_instance(self): - client = _mock_client() - _, proxy = self._make_store_and_proxy(client) + async def test_lifespan_noop_without_fulfillment(self): + spec = MemoryHubExtensionSpec.single_demand() + server = MemoryHubExtensionServer(spec) + server._is_active = True # active but no metadata, no default - await proxy.delete("m-del") - client.delete.assert_awaited_once_with("m-del") + with patch( + "kagenti_adk.server.store.memoryhub_memory_store.MemoryHubClient" + ) as ClientCls: + async with server.lifespan(): + pass + ClientCls.assert_not_called() - async def test_curation_blocked_create_via_proxy(self): - client = _mock_client() - client.write.return_value = _write_result( - memory=None, - curation=SimpleNamespace(reason="blocked"), - ) - _, proxy = self._make_store_and_proxy(client) + # store() outside an active client must raise. + with pytest.raises(RuntimeError): + server.store("ctx") - memory_id = await proxy.create("blocked content") - assert memory_id == "" + async def test_store_outside_lifespan_raises(self): + fulfillment = MemoryHubFulfillment(url="http://hub", api_key=SecretStr("k")) + server = self._server_with_default(fulfillment) + with pytest.raises(RuntimeError): + server.store("ctx-x") # --------------------------------------------------------------------------- -# create_memory_dependency +# httpx integration sanity check (verifies the install path stays wired) # --------------------------------------------------------------------------- -class TestCreateMemoryDependency: - def test_returns_callable(self): - store = MemoryHubMemoryStore(url="http://hub", api_key="k") - dep = create_memory_dependency(store) - assert callable(dep) - - def test_provider_returns_proxy(self): - store = MemoryHubMemoryStore(url="http://hub", api_key="k") - dep = create_memory_dependency(store) - - fake_context = SimpleNamespace(context_id="ctx-dep-1") - proxy = dep(message=None, context=fake_context, request_context=None) - - assert isinstance(proxy, _MemoryProxy) - assert proxy._store is store - assert proxy._context_id == "ctx-dep-1" - - def test_provider_uses_context_id_from_context(self): - store = MemoryHubMemoryStore(url="http://hub", api_key="k") - dep = create_memory_dependency(store) - - proxy_a = dep(None, SimpleNamespace(context_id="ctx-A"), None) - proxy_b = dep(None, SimpleNamespace(context_id="ctx-B"), None) - - assert proxy_a._context_id == "ctx-A" - assert proxy_b._context_id == "ctx-B" - - def test_each_call_returns_new_proxy(self): - store = MemoryHubMemoryStore(url="http://hub", api_key="k") - dep = create_memory_dependency(store) - - ctx = SimpleNamespace(context_id="ctx-same") - p1 = dep(None, ctx, None) - p2 = dep(None, ctx, None) - - assert p1 is not p2 - - async def test_proxy_from_dependency_resolves_correctly(self): - store = MemoryHubMemoryStore(url="http://hub", api_key="k") - client = _mock_client() - store._client = client - client.search.return_value = _search_result( - _memory_obj(id="dep-m1", content="dep content", scope="user") - ) - - dep = create_memory_dependency(store) - proxy = dep(None, SimpleNamespace(context_id="ctx-resolve"), None) +class TestMemoryHubClientImportable: + def test_real_memoryhub_client_importable(self): + # Imports happen at module load; this just asserts they didn't blow up. + from memoryhub.client import MemoryHubClient + from memoryhub.exceptions import NotFoundError - results = await proxy.search("dep query") - assert len(results) == 1 - assert results[0].memory_id == "dep-m1" + assert MemoryHubClient is not None + assert issubclass(NotFoundError, Exception) From d6ba004e84cd43569119ecc86adf90537692e4d5 Mon Sep 17 00:00:00 2001 From: rdwj Date: Tue, 28 Apr 2026 06:35:19 -0500 Subject: [PATCH 10/16] adk-py/store: Drop unnecessary re-exports from store/__init__.py Every consumer in the repo already imports from the concrete module (context_store, memory_store, platform_context_store, etc.). The package-level re-exports added unhelpful indirection and a second place to maintain the symbol list. Assisted-by: Claude Code (Opus 4.7) --- .../src/kagenti_adk/server/store/__init__.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/apps/adk-py/src/kagenti_adk/server/store/__init__.py b/apps/adk-py/src/kagenti_adk/server/store/__init__.py index b41a9336e..7200f7b62 100644 --- a/apps/adk-py/src/kagenti_adk/server/store/__init__.py +++ b/apps/adk-py/src/kagenti_adk/server/store/__init__.py @@ -3,18 +3,3 @@ from __future__ import annotations - -from kagenti_adk.server.store.context_store import ContextStore, ContextStoreInstance -from kagenti_adk.server.store.memory_store import ( - MemoryResult, - MemoryStore, - MemoryStoreInstance, -) - -__all__ = [ - "ContextStore", - "ContextStoreInstance", - "MemoryResult", - "MemoryStore", - "MemoryStoreInstance", -] From 23622531223403d22f81cab4c7ce585f64018a09 Mon Sep 17 00:00:00 2001 From: rdwj Date: Tue, 28 Apr 2026 06:36:43 -0500 Subject: [PATCH 11/16] examples: Add memoryhub-recall E2E example Minimal agent that searches MemoryHub for the user's query and stores one fact from the input. Mirrors llm-proxy-service/llm-access in structure. The matching E2E test fulfills against a real cluster using repo secrets MEMORYHUB_E2E_URL plus either MEMORYHUB_E2E_API_KEY or the OAuth trio (AUTH_URL/CLIENT_ID/CLIENT_SECRET); skips cleanly when secrets aren't set so contributor forks aren't broken. Maintainers: please add the secrets above to enable the test in CI. Assisted-by: Claude Code (Opus 4.7) --- .../memoryhub/test_memoryhub_recall.py | 83 +++++++++++++++++++ .../memoryhub/memoryhub-recall/pyproject.toml | 35 ++++++++ .../src/memoryhub_recall/__init__.py | 2 + .../src/memoryhub_recall/agent.py | 45 ++++++++++ 4 files changed, 165 insertions(+) create mode 100644 apps/adk-server/tests/e2e/examples/agent-integration/memoryhub/test_memoryhub_recall.py create mode 100644 examples/agent-integration/memoryhub/memoryhub-recall/pyproject.toml create mode 100644 examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/__init__.py create mode 100644 examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/agent.py diff --git a/apps/adk-server/tests/e2e/examples/agent-integration/memoryhub/test_memoryhub_recall.py b/apps/adk-server/tests/e2e/examples/agent-integration/memoryhub/test_memoryhub_recall.py new file mode 100644 index 000000000..87f94326e --- /dev/null +++ b/apps/adk-server/tests/e2e/examples/agent-integration/memoryhub/test_memoryhub_recall.py @@ -0,0 +1,83 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +# Maintainer note: this E2E test requires a reachable MemoryHub instance. +# Add the following repository secrets so CI runs the test: +# MEMORYHUB_E2E_URL (required) — public MemoryHub MCP URL +# MEMORYHUB_E2E_API_KEY (one of) — static API key +# MEMORYHUB_E2E_AUTH_URL (or…) +# MEMORYHUB_E2E_CLIENT_ID +# MEMORYHUB_E2E_CLIENT_SECRET — OAuth 2.1 credentials +# When neither credential set is configured the test skips cleanly so +# contributor forks don't fail on a missing secret. + +from __future__ import annotations + +import os + +import pytest +from a2a.client.helpers import create_text_message_object +from a2a.types import SendMessageRequest, TaskState +from kagenti_adk.a2a.extensions import ( + MemoryHubExtensionClient, + MemoryHubExtensionSpec, + MemoryHubFulfillment, +) +from pydantic import SecretStr + +from tests.e2e.examples.conftest import run_example + +pytestmark = pytest.mark.e2e + + +def _fulfillment_from_secrets() -> MemoryHubFulfillment | None: + url = os.environ.get("MEMORYHUB_E2E_URL") + if not url: + return None + api_key = os.environ.get("MEMORYHUB_E2E_API_KEY") + if api_key: + return MemoryHubFulfillment(url=url, api_key=SecretStr(api_key)) + auth_url = os.environ.get("MEMORYHUB_E2E_AUTH_URL") + client_id = os.environ.get("MEMORYHUB_E2E_CLIENT_ID") + client_secret = os.environ.get("MEMORYHUB_E2E_CLIENT_SECRET") + if auth_url and client_id and client_secret: + return MemoryHubFulfillment( + url=url, + auth_url=auth_url, + client_id=client_id, + client_secret=SecretStr(client_secret), + ) + return None + + +@pytest.mark.usefixtures("clean_up", "setup_platform_client") +async def test_memoryhub_recall_example(subtests, get_final_task_from_stream, a2a_client_factory): + fulfillment = _fulfillment_from_secrets() + if fulfillment is None: + pytest.skip( + "MemoryHub E2E secrets not configured (set MEMORYHUB_E2E_URL plus " + "MEMORYHUB_E2E_API_KEY or the OAuth trio)." + ) + + example_path = "agent-integration/memoryhub/memoryhub-recall" + + async with run_example(example_path, a2a_client_factory) as running_example: + spec = MemoryHubExtensionSpec.from_agent_card(running_example.provider.agent_card) + metadata = MemoryHubExtensionClient(spec).fulfillment_metadata( + memoryhub_fulfillments={"default": fulfillment} + ) + + with subtests.test("agent recalls and stores via MemoryHub"): + message = create_text_message_object(content="favorite color is teal") + message.metadata = metadata + message.context_id = running_example.context.id + task = await get_final_task_from_stream( + running_example.client.send_message(SendMessageRequest(message=message)) + ) + + assert task.status.state == TaskState.TASK_STATE_COMPLETED, ( + f"Fail: {task.status.message.parts[0].text}" + ) + text = task.history[-1].parts[0].text + assert "stored 1" in text + assert "recalled" in text diff --git a/examples/agent-integration/memoryhub/memoryhub-recall/pyproject.toml b/examples/agent-integration/memoryhub/memoryhub-recall/pyproject.toml new file mode 100644 index 000000000..0e77c0c04 --- /dev/null +++ b/examples/agent-integration/memoryhub/memoryhub-recall/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "memoryhub-recall" +version = "0.1.0" +description = "Demonstrates cross-session memory recall via the MemoryHub A2A service extension" +authors = [ + { name = "IBM Corp." }, +] +requires-python = ">=3.14,<3.15" +dependencies = [ + "kagenti-adk[memoryhub]>=0.5.3", + "pyyaml>=6.0.2", +] + +[tool.ruff] +line-length = 120 +target-version = "py314" + +[tool.uv.sources] +kagenti-adk = { path = "../../../../apps/adk-py", editable = true } + +[project.scripts] +server = "memoryhub_recall.agent:run" + +[build-system] +requires = ["uv_build>=0.10.0,<0.11.0"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "watchfiles>=1.1.0", +] + +[tool.pyright] +venvPath = "." +venv = ".venv" diff --git a/examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/__init__.py b/examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/__init__.py new file mode 100644 index 000000000..7abe8ca66 --- /dev/null +++ b/examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 diff --git a/examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/agent.py b/examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/agent.py new file mode 100644 index 000000000..7dc6087d7 --- /dev/null +++ b/examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/agent.py @@ -0,0 +1,45 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +import os +from typing import Annotated + +from a2a.types import Message +from a2a.utils.message import get_message_text +from kagenti_adk.a2a.extensions import ( + MemoryHubExtensionSpec, +) +from kagenti_adk.a2a.types import AgentMessage +from kagenti_adk.server import Server +from kagenti_adk.server.context import RunContext +from kagenti_adk.server.store.memoryhub_memory_store import MemoryHubExtensionServer + +server = Server() + + +@server.agent() +async def memoryhub_recall_example( + input: Message, + context: RunContext, + memoryhub: Annotated[MemoryHubExtensionServer, MemoryHubExtensionSpec.single_demand()], +): + """Search MemoryHub for the user's query, then store one fact from the input.""" + query = get_message_text(input) + store = memoryhub.store(context.context_id) + + results = await store.search(query, max_results=5) + await store.create( + f"User mentioned: {query}", + scope="user", + weight=0.7, + ) + + yield AgentMessage(text=f"recalled {len(results)} items, stored 1") + + +def run(): + server.run(host=os.getenv("HOST", "127.0.0.1"), port=int(os.getenv("PORT", 8000))) + + +if __name__ == "__main__": + run() From dbef463b3844fd4e9afa128eb01ded5fba2cb963 Mon Sep 17 00:00:00 2001 From: rdwj Date: Tue, 28 Apr 2026 06:39:09 -0500 Subject: [PATCH 12/16] docs: Rewrite memory.mdx for the A2A extension flow Drops the from_env / Depends / proxy story entirely. The new flow shows the protocol, the server-side MemoryHubExtensionServer.store() accessor, and the client-side MemoryHubExtensionClient fulfillment pattern. Switches install instructions to ``uv add`` and embeds the new memoryhub-recall example via embedme. Closes the remaining doc-review comments on PR #231. Assisted-by: Claude Code (Opus 4.7) --- docs/development/sdk/memory.mdx | 166 +++++++++++++++++--------------- 1 file changed, 88 insertions(+), 78 deletions(-) diff --git a/docs/development/sdk/memory.mdx b/docs/development/sdk/memory.mdx index d02fbf898..85d4ad489 100644 --- a/docs/development/sdk/memory.mdx +++ b/docs/development/sdk/memory.mdx @@ -11,139 +11,147 @@ The interface is backend-agnostic. The built-in implementation uses [MemoryHub]( Two types make up the abstraction: -**`MemoryStore`** — an abstract factory that holds connection configuration and creates per-context instances: - -```python -class MemoryStore(abc.ABC): - @abc.abstractmethod - async def create(self, context_id: str) -> MemoryStoreInstance: ... -``` - **`MemoryStoreInstance`** — the per-context operations protocol: ```python class MemoryStoreInstance(Protocol): async def search(self, query: str, *, scope: str | None = None, project_id: str | None = None, max_results: int = 10) -> list[MemoryResult]: ... - async def write(self, content: str, *, scope: str = "user", weight: float = 0.7, - tags: list[str] | None = None, project_id: str | None = None) -> str: ... + async def create(self, content: str, *, scope: str = "user", weight: float = 0.7, + tags: list[str] | None = None, project_id: str | None = None) -> str: ... async def read(self, memory_id: str) -> MemoryResult | None: ... async def update(self, memory_id: str, content: str) -> None: ... async def delete(self, memory_id: str) -> None: ... ``` -`write()` returns the new `memory_id`, or an empty string if the backend's curation policy rejected the write. - -## MemoryHub implementation +`create()` returns the new `memory_id`, or an empty string if the backend's curation policy rejected the write. The `scope`, `weight`, `tags` and `project_id` arguments are backend-defined; see the protocol docstrings for the worked MemoryHub mapping. -Install the optional dependency: +**`MemoryResult`** — the value type returned from `search()` and `read()`: -```bash -pip install kagenti-adk[memoryhub] +```python +class MemoryResult(BaseModel): + memory_id: str + content: str + scope: str + weight: float = 0.7 + relevance_score: float | None = None ``` -Create a store from environment variables: +## MemoryHub via the A2A service extension -```python -from kagenti_adk.server.store.memoryhub_memory_store import MemoryHubMemoryStore +Add the `memoryhub` extra to your project: -memory_store = MemoryHubMemoryStore.from_env() +```bash +uv add 'kagenti-adk[memoryhub]' ``` -### Environment variables +The MemoryHub backend is wired into agents through an A2A service extension. The agent declares a demand; the calling client supplies a `MemoryHubFulfillment` with a URL and credentials in the request metadata. Inside the agent, the extension exposes a per-context `MemoryHubMemoryStoreInstance`. -OAuth 2.1 (recommended for production): +### Server side (the agent) -| Variable | Description | -|---|---| -| `MEMORYHUB_URL` | MemoryHub service URL | -| `MEMORYHUB_AUTH_URL` | OAuth 2.1 token endpoint | -| `MEMORYHUB_CLIENT_ID` | OAuth client ID | -| `MEMORYHUB_CLIENT_SECRET` | OAuth client secret | +```python +from typing import Annotated -API key (dev/testing): +from a2a.types import Message +from kagenti_adk.a2a.extensions import MemoryHubExtensionSpec +from kagenti_adk.server import Server +from kagenti_adk.server.context import RunContext +from kagenti_adk.server.store.memoryhub_memory_store import MemoryHubExtensionServer -| Variable | Description | -|---|---| -| `MEMORYHUB_URL` | MemoryHub service URL | -| `MEMORYHUB_API_KEY` | Static API key | +server = Server() -When both are present, the API key takes precedence. -## DI integration +@server.agent() +async def my_agent( + input: Message, + context: RunContext, + memoryhub: Annotated[MemoryHubExtensionServer, MemoryHubExtensionSpec.single_demand()], +): + store = memoryhub.store(context.context_id) + results = await store.search("user preferences") + await store.create("User prefers concise responses", scope="user", weight=0.8) + yield f"recalled {len(results)} items" +``` -ADK's `Depends` mechanism is synchronous but `MemoryStore.create()` is async. `create_memory_dependency()` returns a sync provider that wraps the store in a lazy proxy — the underlying `MemoryStoreInstance` is resolved on the first async method call. See [kagenti/adk#229](https://github.com/kagenti/adk/issues/229) for background. +### Client side (calling the agent) ```python -from kagenti_adk.server.store.memoryhub_memory_store import ( - MemoryHubMemoryStore, - create_memory_dependency, +from kagenti_adk.a2a.extensions import ( + MemoryHubExtensionClient, + MemoryHubExtensionSpec, + MemoryHubFulfillment, ) - -memory_store = MemoryHubMemoryStore.from_env() -memory_dep = create_memory_dependency(memory_store) +from pydantic import SecretStr + +spec = MemoryHubExtensionSpec.from_agent_card(agent_card) +metadata = MemoryHubExtensionClient(spec).fulfillment_metadata( + memoryhub_fulfillments={ + "default": MemoryHubFulfillment( + url="https://memoryhub.example.com/mcp/", + api_key=SecretStr("..."), + ) + } +) +# attach metadata to the outgoing A2A message ``` -Pass the dependency to your agent using `Annotated` + `Depends`: +`MemoryHubFulfillment` accepts either an `api_key` (dev/testing path) or the OAuth 2.1 trio `auth_url` + `client_id` + `client_secret`. -```python -from typing import Annotated -from kagenti_adk.server.dependencies import Depends +### Server-side environment fallback -@server.agent() -async def my_agent( - input: Message, - context: RunContext, - memory: Annotated[MemoryHubMemoryStoreInstance, Depends(memory_dep)], -): - ... -``` +When no fulfillment metadata is provided, the extension falls back to environment variables — useful for local development: + +| Variable | Description | +|---|---| +| `MEMORYHUB_URL` | MemoryHub MCP URL (required) | +| `MEMORYHUB_API_KEY` | Static API key (one path) | +| `MEMORYHUB_AUTH_URL` | OAuth 2.1 token endpoint (other path) | +| `MEMORYHUB_CLIENT_ID` | OAuth client ID | +| `MEMORYHUB_CLIENT_SECRET` | OAuth client secret | -## Example +## Full example -A minimal agent that loads relevant context before responding and saves new facts: +A runnable agent that searches MemoryHub for the user's input, then stores one fact from it: +{/* */} ```python +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + import os from typing import Annotated from a2a.types import Message +from a2a.utils.message import get_message_text +from kagenti_adk.a2a.extensions import ( + MemoryHubExtensionSpec, +) +from kagenti_adk.a2a.types import AgentMessage from kagenti_adk.server import Server from kagenti_adk.server.context import RunContext -from kagenti_adk.server.dependencies import Depends -from kagenti_adk.server.store.memoryhub_memory_store import ( - MemoryHubMemoryStore, - MemoryHubMemoryStoreInstance, - create_memory_dependency, -) +from kagenti_adk.server.store.memoryhub_memory_store import MemoryHubExtensionServer server = Server() -memory_store = MemoryHubMemoryStore.from_env() -memory_dep = create_memory_dependency(memory_store) @server.agent() -async def memory_agent( +async def memoryhub_recall_example( input: Message, context: RunContext, - memory: Annotated[MemoryHubMemoryStoreInstance, Depends(memory_dep)], + memoryhub: Annotated[MemoryHubExtensionServer, MemoryHubExtensionSpec.single_demand()], ): - query = input.parts[0].text + """Search MemoryHub for the user's query, then store one fact from the input.""" + query = get_message_text(input) + store = memoryhub.store(context.context_id) - # Load relevant memories before processing - results = await memory.search(query, max_results=5) - context_block = "\n".join(r.content for r in results) - - # ... call LLM with context_block + query ... - - # Persist anything worth remembering - await memory.write( - "User prefers concise responses", + results = await store.search(query, max_results=5) + await store.create( + f"User mentioned: {query}", scope="user", - weight=0.8, + weight=0.7, ) - yield f"[recalled {len(results)} memories]\n..." + yield AgentMessage(text=f"recalled {len(results)} items, stored 1") def run(): @@ -154,6 +162,8 @@ if __name__ == "__main__": run() ``` +The example lives at `examples/agent-integration/memoryhub/memoryhub-recall`. + Use `scope="project"` with a `project_id` to share memories across multiple agents working on the same project, rather than scoping them to a single user. From 5d8dbcd4fd25933a2b578110621fd9b6272e29f0 Mon Sep 17 00:00:00 2001 From: rdwj Date: Tue, 28 Apr 2026 06:39:17 -0500 Subject: [PATCH 13/16] adk-py: Fix ruff lint findings introduced by the rework - Replace en-dash with hyphen in MemoryStore docstrings (RUF001/RUF002). - Drop unused MagicMock import (F401). - Rename test variable ClientCls -> client_cls (N806). Caught running ``uv run ruff check`` locally before pushing the rework. Assisted-by: Claude Code (Opus 4.7) --- .../src/kagenti_adk/server/store/memory_store.py | 4 ++-- .../tests/unit/server/store/test_memory_store.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/adk-py/src/kagenti_adk/server/store/memory_store.py b/apps/adk-py/src/kagenti_adk/server/store/memory_store.py index 7c7d1d62f..410acc8d2 100644 --- a/apps/adk-py/src/kagenti_adk/server/store/memory_store.py +++ b/apps/adk-py/src/kagenti_adk/server/store/memory_store.py @@ -50,7 +50,7 @@ class MemoryResult(BaseModel): weight: float = Field( default=0.7, description=( - "Priority/curation signal in the range 0.0–1.0. Backends may use " + "Priority/curation signal in the range 0.0-1.0. Backends may use " "it for ranking or ignore it." ), ) @@ -74,7 +74,7 @@ class MemoryStoreInstance(Protocol): - ``scope``: Visibility/governance domain. Backend-defined; in MemoryHub: one of user/project/campaign/organizational/enterprise. - - ``weight``: Priority/curation signal in the range 0.0–1.0. Backends + - ``weight``: Priority/curation signal in the range 0.0-1.0. Backends may use it for ranking or ignore it. - ``tags``: Free-form tags for grouping/filtering. Backend-defined semantics; in MemoryHub: "domains" attached to a memory. diff --git a/apps/adk-py/tests/unit/server/store/test_memory_store.py b/apps/adk-py/tests/unit/server/store/test_memory_store.py index f8fdc7595..7af176fec 100644 --- a/apps/adk-py/tests/unit/server/store/test_memory_store.py +++ b/apps/adk-py/tests/unit/server/store/test_memory_store.py @@ -12,7 +12,7 @@ from __future__ import annotations from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest from pydantic import SecretStr @@ -325,7 +325,7 @@ async def test_lifespan_opens_and_closes_api_key_client(self): with patch( "kagenti_adk.server.store.memoryhub_memory_store.MemoryHubClient", return_value=fake_client, - ) as ClientCls: + ) as client_cls: async with server.lifespan(): # Inside the lifespan, store() should hand back a usable instance. inst = server.store("ctx-1") @@ -333,7 +333,7 @@ async def test_lifespan_opens_and_closes_api_key_client(self): assert inst._context_id == "ctx-1" assert inst._client is fake_client - ClientCls.assert_called_once_with(url="http://hub", api_key="the-key") + client_cls.assert_called_once_with(url="http://hub", api_key="the-key") fake_client.__aenter__.assert_awaited_once() fake_client.__aexit__.assert_awaited_once_with(None, None, None) @@ -354,11 +354,11 @@ async def test_lifespan_uses_oauth_path(self): with patch( "kagenti_adk.server.store.memoryhub_memory_store.MemoryHubClient", return_value=fake_client, - ) as ClientCls: + ) as client_cls: async with server.lifespan(): pass - ClientCls.assert_called_once_with( + client_cls.assert_called_once_with( url="http://hub", auth_url="http://auth", client_id="cid", @@ -372,10 +372,10 @@ async def test_lifespan_noop_without_fulfillment(self): with patch( "kagenti_adk.server.store.memoryhub_memory_store.MemoryHubClient" - ) as ClientCls: + ) as client_cls: async with server.lifespan(): pass - ClientCls.assert_not_called() + client_cls.assert_not_called() # store() outside an active client must raise. with pytest.raises(RuntimeError): From 481a68f709ec890f52534520894ab5c1880caf4a Mon Sep 17 00:00:00 2001 From: rdwj Date: Wed, 29 Apr 2026 10:14:44 -0400 Subject: [PATCH 14/16] adk-py: Raise MemoryRejectionError on memory store rejection Replace the empty-string sentinel return on rejected writes with a typed MemoryRejectionError. Empty-string-as-error was easy for callers to miss; the exception carries the backend's reason and matches the existing ToolCallRejectionError / ApprovalRejectionError pattern in the SDK. Addresses review feedback on PR #231. Assisted-by: Claude Code (Opus 4.7) --- .../kagenti_adk/server/store/exceptions.py | 22 +++++++++++++++++++ .../kagenti_adk/server/store/memory_store.py | 2 ++ .../server/store/memoryhub_memory_store.py | 5 ++--- .../unit/server/store/test_memory_store.py | 8 ++++--- docs/development/sdk/memory.mdx | 13 ++++++++++- 5 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 apps/adk-py/src/kagenti_adk/server/store/exceptions.py diff --git a/apps/adk-py/src/kagenti_adk/server/store/exceptions.py b/apps/adk-py/src/kagenti_adk/server/store/exceptions.py new file mode 100644 index 000000000..2dd38fc7a --- /dev/null +++ b/apps/adk-py/src/kagenti_adk/server/store/exceptions.py @@ -0,0 +1,22 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + + +class MemoryRejectionError(RuntimeError): + """Raised when the memory store refused to record a memory. + + Backends that run a pre-storage pipeline (deduplication, contradiction + detection, policy/curator rules) may reject a write. The ``reason`` + attribute carries the backend's explanation when one is provided. + """ + + def __init__(self, reason: str | None = None): + msg = ( + f"Memory store rejected the memory: {reason}" + if reason + else "Memory store rejected the memory" + ) + super().__init__(msg) + self.reason = reason diff --git a/apps/adk-py/src/kagenti_adk/server/store/memory_store.py b/apps/adk-py/src/kagenti_adk/server/store/memory_store.py index 410acc8d2..d7361f54f 100644 --- a/apps/adk-py/src/kagenti_adk/server/store/memory_store.py +++ b/apps/adk-py/src/kagenti_adk/server/store/memory_store.py @@ -113,6 +113,8 @@ async def create( ``scope``, ``weight``, ``tags`` and ``project_id`` follow the cross-method conventions documented on the class. + + Implementations may raise to signal that the backend rejected the write. """ ... diff --git a/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py index f6edea2ce..65c74c411 100644 --- a/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py +++ b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py @@ -29,6 +29,7 @@ from kagenti_adk.a2a.extensions.services.memoryhub import ( MemoryHubExtensionServer as _BaseMemoryHubExtensionServer, ) +from kagenti_adk.server.store.exceptions import MemoryRejectionError from kagenti_adk.server.store.memory_store import MemoryResult, MemoryStoreInstance if TYPE_CHECKING: @@ -91,9 +92,7 @@ async def create( project_id=project_id, ) if result.memory is None: - # Curation gated the create — return empty string to signal no-op - logger.warning("MemoryHub curation gated create: %s", result.curation.reason) - return "" + raise MemoryRejectionError(result.curation.reason) return result.memory.id async def read(self, memory_id: str) -> MemoryResult | None: diff --git a/apps/adk-py/tests/unit/server/store/test_memory_store.py b/apps/adk-py/tests/unit/server/store/test_memory_store.py index 7af176fec..e1254e742 100644 --- a/apps/adk-py/tests/unit/server/store/test_memory_store.py +++ b/apps/adk-py/tests/unit/server/store/test_memory_store.py @@ -22,6 +22,7 @@ MemoryHubExtensionSpec, MemoryHubFulfillment, ) +from kagenti_adk.server.store.exceptions import MemoryRejectionError from kagenti_adk.server.store.memory_store import MemoryResult from kagenti_adk.server.store.memoryhub_memory_store import ( MemoryHubExtensionServer, @@ -215,7 +216,7 @@ async def test_create_passes_all_params(self): project_id="proj-1", ) - async def test_create_returns_empty_string_when_curation_blocks(self): + async def test_create_raises_on_curation_rejection(self): client = _mock_client() client.write.return_value = _write_result( memory=None, @@ -223,8 +224,9 @@ async def test_create_returns_empty_string_when_curation_blocks(self): ) inst = self._make(client) - memory_id = await inst.create("duplicate content") - assert memory_id == "" + with pytest.raises(MemoryRejectionError) as exc_info: + await inst.create("duplicate content") + assert exc_info.value.reason == "duplicate detected" # --- read --- diff --git a/docs/development/sdk/memory.mdx b/docs/development/sdk/memory.mdx index 85d4ad489..5be1b4661 100644 --- a/docs/development/sdk/memory.mdx +++ b/docs/development/sdk/memory.mdx @@ -24,7 +24,18 @@ class MemoryStoreInstance(Protocol): async def delete(self, memory_id: str) -> None: ... ``` -`create()` returns the new `memory_id`, or an empty string if the backend's curation policy rejected the write. The `scope`, `weight`, `tags` and `project_id` arguments are backend-defined; see the protocol docstrings for the worked MemoryHub mapping. +`create()` returns the new `memory_id`, or raises `MemoryRejectionError` if the backend rejected the write. The `scope`, `weight`, `tags` and `project_id` arguments are backend-defined; see the protocol docstrings for the worked MemoryHub mapping. + +Handle rejection like this: + +```python +from kagenti_adk.server.store.exceptions import MemoryRejectionError + +try: + memory_id = await store.create("user prefers dark mode", scope="user") +except MemoryRejectionError as e: + logger.info("Memory rejected: %s", e.reason) +``` **`MemoryResult`** — the value type returned from `search()` and `read()`: From a853d7ac6bdcbeffd15656f490dbccb707b0ff21 Mon Sep 17 00:00:00 2001 From: rdwj Date: Wed, 29 Apr 2026 10:23:25 -0400 Subject: [PATCH 15/16] adk-py: Comment MemoryHub rejection signal at the SDK boundary Document that memoryhub.WriteResult.memory is None when the SDK's curation pipeline rejects a write. The contract is invisible at this call site without the comment, and the boundary between two systems warrants a pointer. Assisted-by: Claude Code (Opus 4.7) --- .../src/kagenti_adk/server/store/memoryhub_memory_store.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py index 65c74c411..c33f9c5b5 100644 --- a/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py +++ b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py @@ -91,6 +91,7 @@ async def create( domains=tags, project_id=project_id, ) + # memoryhub.WriteResult.memory is None when the SDK's curation pipeline rejected the write if result.memory is None: raise MemoryRejectionError(result.curation.reason) return result.memory.id From 79ad3d28945b3214196d41660aace6e7f85972a5 Mon Sep 17 00:00:00 2001 From: rdwj Date: Wed, 29 Apr 2026 14:21:19 -0400 Subject: [PATCH 16/16] adk-py: Bump memoryhub pin to >=0.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Required to talk to the deployed primary memory-hub-mcp server, which exposes only the unified memory(action=...) MCP tool after the upstream consolidation in redhat-ai-americas/memory-hub#198, #202. SDK 0.6.x calls the legacy per-action tool names and fails end-to-end against that deployment. Public Python API of memoryhub is unchanged — the existing MemoryHubMemoryStoreInstance wrapper still compiles and all 24 adk-py memory store unit tests pass against 0.7.0 with no source changes required. Tracks redhat-ai-americas/memory-hub#210. Signed-off-by: rdwj --- apps/adk-py/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/adk-py/pyproject.toml b/apps/adk-py/pyproject.toml index 842466c85..1a29e3654 100644 --- a/apps/adk-py/pyproject.toml +++ b/apps/adk-py/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ ] [project.optional-dependencies] -memoryhub = ["memoryhub>=0.5.0"] +memoryhub = ["memoryhub>=0.7.0"] [dependency-groups] dev = [