feat: Add MemoryStore protocol for cross-session agent memory#231
Conversation
fc092c8 to
766aa67
Compare
JanPokorny
left a comment
There was a problem hiding this comment.
Hi, thank you for the PR. I added some feedback to help align with the current ADK patterns. Ping back if something does not make sense to you.
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 kagenti#231. Assisted-by: Claude Code (Opus 4.7)
|
Thanks for the careful review. I've pushed a rework on
A trailing |
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 kagenti#231. Assisted-by: Claude Code (Opus 4.7)
|
Pausing this PR — found that the upstream |
…208) * planning: Add design for SDK rework against compacted tool surface The memoryhub SDK still calls per-action MCP tools but the deployed primary server only exposes register_session + memory after #198/#202. Issue #202 called for keeping per-action tools as deprecation aliases; that step was skipped. The kagenti-adk integration in kagenti/adk#231 hit this when run against the live server. Decision recorded: take the SDK forward to the new surface rather than back-porting server-side aliases. Public method signatures stay stable; only the wire format changes. Bump SDK 0.6.0 → 0.7.0. Tracks #210. Assisted-by: Claude Code (Opus 4.7) Signed-off-by: rdwj <wjackson@redhat.com> * sdk: Rework client against compacted memory(action=...) tool surface Every public MemoryHubClient method now dispatches through the unified `memory` tool (#198/#202) instead of the legacy per-action tool names. Public Python API is unchanged — only the wire format changes. This restores end-to-end behavior against the primary memory-hub-mcp deployment, which exposes only register_session + memory after the consolidation. The cutover replaces the deprecation-alias path that was scoped in #202 but never shipped. Signature stability is the load-bearing property here: the kagenti-adk MemoryStore wrapper (kagenti/adk#231) calls search, write, read, update, delete, etc. directly — those signatures are preserved, so consumers only need to bump the dependency pin. Smoke-tested against the live primary server: search, get_session, and list_projects all succeed. Note: server-side max_results behavior on search appears to over-return appendix entries; that's a separate server bug, not SDK. Tracks #210. Assisted-by: Claude Code (Opus 4.7) Signed-off-by: rdwj <wjackson@redhat.com> * sdk: Bump to 0.7.0 with BREAKING wire-format note The 0.6.0 → 0.7.0 cutover swaps every MCP tool call from the legacy per-action names to memory(action=..., options={...}). Public Python API is unchanged but the wire format is incompatible with servers that only expose the per-action surface, and 0.6.0 is incompatible with the primary memory-hub-mcp deployment. Tracks #210. Assisted-by: Claude Code (Opus 4.7) Signed-off-by: rdwj <wjackson@redhat.com> * sdk: Add kagenti-adk contract test (#208) Pins the SDK surface that kagenti-adk's MemoryHubMemoryStoreInstance depends on: constructor (api_key + OAuth modes), search/write/read/ update/delete signatures, the WriteResult.curation.reason path that the wrapper raises MemoryRejectionError on, and NotFoundError on missing-id reads/deletes. Uses the SDK's existing mocked transport — no live server needed. A failure here is the cue to coordinate with kagenti-adk maintainers before shipping the SDK release. Closes #208. Assisted-by: Claude Code (Opus 4.7) Signed-off-by: rdwj <wjackson@redhat.com> --------- Signed-off-by: rdwj <wjackson@redhat.com>
|
Unpausing — fixed the underlying issue and pushed Background. The Upstream fix. redhat-ai-americas/memory-hub#211 — reworks New SDK contract test. redhat-ai-americas/memory-hub#208 / Verification done locally. All 24 One snag for your CI / re-test. This repo's
I left the lock alone so you can choose. Happy to push the lock update as a follow-up commit if you'd prefer option 2. |
|
MemoryHub backend is live for the E2E secrets you have. Quick status on the redhat-ai-americas/memory-hub#207 work this is paired with:
One small request that closes the cleanup story cleanly. The current If the example switches its result = await store.create(
f"User mentioned: {query}",
scope="project",
project_id="kagenti-tests",
weight=0.7,
)then:
For the cleanup itself, the simplest pattern given today's API surface is capture-and-delete in the test fixture: created_ids = []
# ... after each store.create(...):
created_ids.append(result.memory.id)
# in teardown:
for memory_id in created_ids:
await store.delete(memory_id)That handles the happy path. Crashed-test orphans aren't auto-swept by this — there's no The example switch is the only thing that closes #207 cleanly. Happy to land it as a follow-up commit on this PR if it's easier for you. |
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 <wjackson@redhat.com>
…ation 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 <wjackson@redhat.com>
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 <wjackson@redhat.com>
- 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 <wjackson@redhat.com>
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 kagenti#229. Assisted-by: Claude Code (Opus 4.7) Signed-off-by: rdwj <wjackson@redhat.com>
`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) Signed-off-by: rdwj <wjackson@redhat.com>
…mantics 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) Signed-off-by: rdwj <wjackson@redhat.com>
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 kagenti#231. Assisted-by: Claude Code (Opus 4.7) Signed-off-by: rdwj <wjackson@redhat.com>
- 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) Signed-off-by: rdwj <wjackson@redhat.com>
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 kagenti#231. Assisted-by: Claude Code (Opus 4.7) Signed-off-by: rdwj <wjackson@redhat.com>
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) Signed-off-by: rdwj <wjackson@redhat.com>
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, kagenti#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 <wjackson@redhat.com>
Move the example's store.create() from scope="user" to scope="project" with project_id="kagenti-tests". The kagenti-tests project on the deployed primary is invite_only=true with kagenti-ci as its only non-admin member, so the project-scope write is hard- enforced — any other actor attempting the same write hits ProjectInviteOnlyError. This also gives the test a single named project to point future cleanup sweeps at, instead of leaning on user-scope's implicit per-user isolation. Doc embed updated alongside (embedme keeps memory.mdx in sync with the example source). Closes the cleanup story for redhat-ai-americas/memory-hub#207. Assisted-by: Claude Code (Opus 4.7 1M) Signed-off-by: rdwj <wjackson@redhat.com>
The memoryhub pin was bumped to >=0.7.0 in the prior commit so the SDK can talk to the deployed primary memory-hub-mcp server, but the lock still resolved to 0.5.1 because of the project's exclude-newer = "3 days" policy. memoryhub 0.7.0 was uploaded 2026-04-29; the policy window has cleared, so a plain ``uv lock --upgrade-package memoryhub`` now advances it to 0.7.0 without relaxing exclude-newer-span. No source changes — the SDK's public Python API is unchanged between 0.5.x and 0.7.0, so the existing MemoryHubMemoryStoreInstance wrapper compiles and passes all 24 adk-py memory-store unit tests against 0.7.0. Assisted-by: Claude Code (Opus 4.7 1M) Signed-off-by: rdwj <wjackson@redhat.com>
79ad3d2 to
d48ed8d
Compare
|
@JanPokorny — settled up on my side. Pushed DCO is green. Eleven commits in the middle of the rework cascade had dropped their Example switched to
The empty-string → Ready for another pass when your queue clears. |
|
@rdwj Hello, sorry for the wait. Regarding e2e tests, can you make it so that the URL drift is detected and the test is pytest.xfail'ed in that case? So that it does not break CI "mysteriously" but actually records in a meaningful way.
Yes please, you can put it here. Looks like some CI checks failed, can you look into that? They are all named like the commands they run, so you can verify locally. Tip: "mise fix" to autofix simple stuff. |
|
@JanPokorny — pushed four commits on top of
Validated locally end-to-end against the deployed primary MemoryHub:
The example switch (closes #207) is in Two failing CI checks — |
The `adk-py:setup` task syncs with `--all-extras --dev`, but `adk-py:test-all` runs `uv run --python=<v> pytest` without `--all-extras`. When the requested Python differs from the project default, `uv run` provisions a fresh venv with only the base + dev groups, so optional-extra-gated tests fail at collection on that Python (e.g. `ModuleNotFoundError: memoryhub` on the 3.11 lane after this PR introduces the optional `memoryhub` extra). Pass `--all-extras` to `uv run` so test-all matches setup's install scope on every Python version. The only optional extra in the project today is `memoryhub`, so the surface impact is just installing `memoryhub>=0.7.0` in the 3.11 CI venv. Assisted-by: Claude Code (Opus 4.7) Signed-off-by: rdwj <wjackson@redhat.com>
`mise check` failed on `adk-py:check:ruff-format` against the memoryhub modules and tests. Ran `mise run adk-py:fix` to apply the project's ruff format rules; no semantic changes — just the line collapses ruff prefers within the 120-column limit. Affected files: src/kagenti_adk/a2a/extensions/services/memoryhub.py src/kagenti_adk/server/store/exceptions.py src/kagenti_adk/server/store/memory_store.py src/kagenti_adk/server/store/memoryhub_memory_store.py tests/unit/a2a/extensions/services/test_memoryhub.py tests/unit/server/store/test_memory_store.py `mise run adk-py:check` is now green locally. Assisted-by: Claude Code (Opus 4.7) Signed-off-by: rdwj <wjackson@redhat.com>
The reviewer asked for the URL-drift case to xfail rather than break CI
mysteriously. URL drift here means: ``MEMORYHUB_E2E_URL`` is set, but the
backend at that URL has moved, the cert expired, or the credentials no
longer authenticate. Without a probe, that surfaces as a generic
``task.status.state == FAILED`` assertion deep in the example agent's
streamed task — fixable, but only after re-deploying the platform in CI
and reading the agent logs.
Add ``_xfail_on_backend_drift()``: a pre-flight probe that opens a
``MemoryHubClient`` with the same fulfillment the example agent will use
and calls ``get_session()`` (a read-only no-op).
Classification walks the exception's ``__cause__`` / ``__context__``
chain. FastMCP's ``_connect`` re-raises transport errors as a generic
``RuntimeError("Client failed to connect: ...")``, so ``httpx.ConnectError``
on a DNS failure only surfaces in ``__cause__``. Walking the chain
catches that and the four error families that indicate drift —
``AuthenticationError``, ``ConnectionFailedError``, other
``MemoryHubError``, transport-level ``httpx.HTTPError`` / ``OSError`` —
and translates them into ``pytest.xfail`` with a reason that names the
URL and exception. Anything else still bubbles up as a real failure.
Validated locally against the deployed primary MemoryHub: bad host →
xfail (transport, ConnectError), wrong host (405) → xfail (transport,
HTTPStatusError), bad API key → xfail (auth), real URL + key → pass.
The probe needs the ``memoryhub`` SDK in the adk-server pytest
environment, which only has it when adk-py's ``[memoryhub]`` extra is
installed. Guard with ``pytest.importorskip("memoryhub")`` so the rest
of the adk-server e2e suite still collects when the extra isn't there.
Assisted-by: Claude Code (Opus 4.7)
Signed-off-by: rdwj <wjackson@redhat.com>
adk-py's ``[memoryhub]`` optional-dependencies extra was added but adk-server's ``uv.lock`` (which pins adk-py via the ``editable`` path source) wasn't refreshed alongside. ``uv sync`` in the adk-server project root populates the lock with the matching ``provides-extras`` and ``memoryhub`` marker entries. The routine ``exclude-newer`` window roll (3-day span) comes along with it; no third-party dep versions change. Assisted-by: Claude Code (Opus 4.7) Signed-off-by: rdwj <wjackson@redhat.com>
5e5dc35 to
dcfc3d9
Compare
|
The The integration and e2e failures are separate. This PR is from my fork ( Could you push this branch to Separately, I'd like to file a small follow-up to make |
The docs:check:pyrefly task extracts ```python``` blocks whose first line is a from/import and type-checks them in isolation. Two snippets in memory.mdx assumed surrounding context that pyrefly can't see: - The MemoryRejectionError example used `await` at module top-level with undefined `store` and `logger` names. Wrap it in an async function and add the missing imports. - The client-side fulfillment_metadata example used an undefined `agent_card` and ignored the Optional return from `from_agent_card`. Wrap it in a function that takes an AgentCard and narrows the spec. Verified locally with `mise run docs:check:pyrefly` (0 errors). Assisted-by: Claude Code (Opus 4.7) Signed-off-by: rdwj <wjackson@redhat.com>
`pytest_configure` calls `Configuration().model_dump()` for the
integration and e2e markers, which forces evaluation of the
`llm_provider_type` computed field. With an empty `LLM_MODEL`, that
called `ModelProviderType("")` and raised `ValueError` as an
INTERNALERROR before any test could run.
This was the failure mode on fork PRs, where `vars.OPENAI_MODEL` does
not resolve and `LLM_MODEL` is an empty string. The companion change in
.github/workflows/ci.yaml gates the integration and e2e jobs on
same-repo PRs so they no longer attempt to run on forks; this conftest
change is defense-in-depth so a stray run (or a local invocation
without LLM_MODEL set) produces a clean skip path rather than an
INTERNALERROR.
Behavior change is narrow: when `llm_model` is empty, the computed
field returns `None` and `model_dump()` serializes the field as None
without raising. Both existing callers
(apps/adk-server/tests/e2e/conftest.py:66 and
apps/adk-server/tests/e2e/routes/test_configurations.py:27) only run
under the `e2e` marker which is gated out on fork PRs, so the new
`Optional` return type does not reach a path that would dereference it
in the configurations actually exercised by CI.
Assisted-by: Claude Code (Opus 4.7)
Signed-off-by: rdwj <wjackson@redhat.com>
`pull_request` events from forks do not receive `vars.*` or `secrets.*` exposure for security reasons. The integration and e2e jobs both consume `vars.OPENAI_MODEL` (as `LLM_MODEL`), `secrets.OPENAI_API_KEY`, `secrets.OPENAI_API_BASE`, and the e2e job additionally consumes the OIDC provider configuration. With those values empty, the integration job crashes at conftest collection time (`ValueError: '' is not a valid ModelProviderType`) and the e2e job times out at 25 minutes waiting on `Deployment/kagenti-adk/adk-ui` whose pods cannot start without OIDC configuration. Adding `head.repo.full_name == github.repository` to the existing `if:` skips both jobs on fork PRs while preserving the unchanged behavior on push, workflow_dispatch, and same-repo PRs. The adk-server-test-e2e-examples job was already protected by a different gate (the `e2e-examples` label) and is left untouched. The companion conftest change in apps/adk-server/tests/conftest.py keeps the failure mode tolerable as defense-in-depth: a stray run that somehow reaches conftest with empty `LLM_MODEL` will return None from `llm_provider_type` rather than raising INTERNALERROR. Assisted-by: Claude Code (Opus 4.7) Signed-off-by: rdwj <wjackson@redhat.com>
fb2ca18 to
f6fc7e0
Compare
|
@JanPokorny Pushed three commits and force-updated the branch.
What you should see on the new run for this PR. Confidence the underlying tests still work end-to-end. Re-running on this fork won't actually exercise integration/e2e (that's the whole point of the gate), so you don't get post-merge-only confidence here. Two options when you're ready:
I'd lean toward (1) for cleanest pre-merge signal but either is fine. Local verification on my side: 24/24 |
Summary
Adds a
MemoryStoreprotocol and a MemoryHub-backed implementation for governed, cross-session agent memory. This is the ADK component of the integration proposal in kagenti/kagenti#1334.MemoryStorecomplementsContextStore: whereContextStorereplays the current conversation,MemoryStoreprovides durable knowledge that persists across sessions — preferences, decisions, and project context that agents should remember regardless of which conversation is active.What's included:
MemoryStoreprotocol andMemoryStoreInstanceProtocol inkagenti_adk.server.store— follows the same Protocol + ABC +create(context_id)factory pattern asContextStoreMemoryHubMemoryStoreimplementation wrapping thememoryhubPython SDK (optional dependency viapip install kagenti-adk[memoryhub])create_memory_dependency()helper for the ADKDependsDI pattern — includes a lazy proxy workaround for Depends does not await async dependency callables #229docs/development/sdk/memory.mdxValidated in PoC (2026-04-23): Memory write, semantic search recall, and pod restart survival confirmed end-to-end on a fresh RHPDS cluster with Kagenti + MemoryHub + test agent. Details in kagenti/kagenti#1334.
Linked Issues
Relates to kagenti/kagenti#1334 (integration proposal)
Workaround for #229 (
Dependsdoesn't await async callables)Documentation
Documentation is included:
docs/development/sdk/memory.mdx, registered indocs.jsonunder the "Using the SDK" group (development version)./cc @Ladas