Skip to content

feat: Add MemoryStore protocol for cross-session agent memory#231

Merged
JanPokorny merged 25 commits into
kagenti:mainfrom
rdwj:feat/memory-store-protocol
May 11, 2026
Merged

feat: Add MemoryStore protocol for cross-session agent memory#231
JanPokorny merged 25 commits into
kagenti:mainfrom
rdwj:feat/memory-store-protocol

Conversation

@rdwj
Copy link
Copy Markdown
Contributor

@rdwj rdwj commented Apr 26, 2026

Summary

Adds a MemoryStore protocol and a MemoryHub-backed implementation for governed, cross-session agent memory. This is the ADK component of the integration proposal in kagenti/kagenti#1334.

MemoryStore complements ContextStore: where ContextStore replays the current conversation, MemoryStore provides durable knowledge that persists across sessions — preferences, decisions, and project context that agents should remember regardless of which conversation is active.

What's included:

  • MemoryStore protocol and MemoryStoreInstance Protocol in kagenti_adk.server.store — follows the same Protocol + ABC + create(context_id) factory pattern as ContextStore
  • MemoryHubMemoryStore implementation wrapping the memoryhub Python SDK (optional dependency via pip install kagenti-adk[memoryhub])
  • create_memory_dependency() helper for the ADK Depends DI pattern — includes a lazy proxy workaround for Depends does not await async dependency callables #229
  • 42 unit tests covering all methods, edge cases, DI lifecycle, and curation-gated writes
  • Developer documentation in docs/development/sdk/memory.mdx

Validated 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 (Depends doesn't await async callables)

Documentation

Documentation is included: docs/development/sdk/memory.mdx, registered in docs.json under the "Using the SDK" group (development version).

/cc @Ladas

Copy link
Copy Markdown
Contributor

@JanPokorny JanPokorny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/development/sdk/memory.mdx Outdated
Comment thread apps/adk-py/src/kagenti_adk/server/store/__init__.py Outdated
Comment thread apps/adk-py/src/kagenti_adk/server/store/memory_store.py Outdated
Comment thread apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py Outdated
Comment thread apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py Outdated
Comment thread docs/development/sdk/memory.mdx
Comment thread apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py Outdated
Comment thread apps/adk-py/src/kagenti_adk/server/store/memory_store.py
Comment thread apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py Outdated
rdwj added a commit to rdwj/adk that referenced this pull request Apr 28, 2026
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)
@rdwj
Copy link
Copy Markdown
Contributor Author

rdwj commented Apr 28, 2026

@JanPokorny

Thanks for the careful review. I've pushed a rework on feat/memory-store-protocol. Here's the comment-by-commit map:

  1. Depends doesn't await async callablesa327e12e moves resolution into the lifespan() body and awaits any awaitable; the proxy workaround is gone in commit 5.
  2. MemoryStoreInstance.write should be create1e36b232 renames at the protocol level and propagates.
  3. Protocol leaks MemoryHub vocabulary (scope/weight/tags/project_id)5c0336ab keeps the fields but documents them as backend-defined with MemoryHub mapped as a worked example. The "tenancy boundary" wording you flagged on project_id is corrected — tenancy comes from the backend's auth credentials.
  4. No A2A extension for MemoryHub; from_env is a magic factory898a500f adds the extension shape line-for-line on services/llm.py. Env vars are kept only as the server-side fallback inside _memoryhub_fulfillment_from_env() (mirrors _llm_fulfillment_from_env).
  5. MemoryHubMemoryStore / _MemoryProxy / create_memory_dependency are awkward6158798a deletes them. MemoryHubExtensionServer now owns the client lifecycle via its A2A lifespan() and exposes store(context_id) -> MemoryHubMemoryStoreInstance.
  6. Re-exports in server/store/__init__.pyd6ba004e removes them; every consumer in the repo already imports from the concrete module.
  7. No working example23622531 adds examples/agent-integration/memoryhub/memoryhub-recall plus the matching E2E test. It skips cleanly when MEMORYHUB_E2E_* repo secrets aren't set; please add them when you're ready.
  8. memory.mdx still describes the old from_env/proxy flowdbef463b rewrites it for the extension flow, switches install to uv add, and embeds the new example via embedme.
  9. Module-top imports for memoryhub → rolled into 6158798a; memoryhub is the optional extra, so the imports live at module top now.

A trailing 5d8dbcd4 cleans up three ruff findings the rework introduced. Local: 123 unit tests pass, ruff check clean.

Comment thread apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py Outdated
rdwj added a commit to rdwj/adk that referenced this pull request Apr 29, 2026
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)
@rdwj
Copy link
Copy Markdown
Contributor Author

rdwj commented Apr 29, 2026

Pausing this PR — found that the upstream memoryhub Python SDK is wholesale broken against the compacted-tool memory-hub server (issue redhat-ai-americas/memory-hub#198 dropped per-action MCP tools in favor of a single memory(action="...") dispatcher; the SDK still calls search_memory / read_memory / etc.). Will rework the SDK against the new surface and then ping for the next review pass.

rdwj added a commit to redhat-ai-americas/memory-hub that referenced this pull request Apr 29, 2026
…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>
@rdwj
Copy link
Copy Markdown
Contributor Author

rdwj commented Apr 29, 2026

Unpausing — fixed the underlying issue and pushed 79ad3d28 bumping the pin to memoryhub>=0.7.0.

Background. The memoryhub SDK was wholesale broken against the deployed primary memory-hub-mcp server. Upstream had consolidated 10 per-action MCP tools into a single memory(action=...) dispatcher (redhat-ai-americas/memory-hub#198, #202) but the SDK still called the legacy tool names — every operational method raised Unknown tool end-to-end, including the search/write/read/update/delete path your MemoryHubMemoryStoreInstance wrapper exercises. The kagenti-ci HTTP-200 smoke test against the route only confirmed the route was alive, not that any memory operation worked.

Upstream fix. redhat-ai-americas/memory-hub#211 — reworks MemoryHubClient to dispatch every operation through the unified tool, keeping all public Python signatures stable. Released as memoryhub==0.7.0 (https://pypi.org/project/memoryhub/0.7.0/). The wrapper code in this PR did not need to change; just the dependency pin.

New SDK contract test. redhat-ai-americas/memory-hub#208 / sdk/tests/test_sdk_kagenti_contract.py was added in the same upstream PR. It pins exactly the surface this wrapper depends on: constructor (api_key + OAuth modes), search/write/read/update/delete, the WriteResult.curation.reason path that MemoryRejectionError raises on, and NotFoundError on missing-id reads/deletes. A failure there in the future is the cue to coordinate with you before shipping.

Verification done locally. All 24 tests/unit/server/store/test_memory_store.py cases pass against memoryhub==0.7.0 with no source changes. Ruff is clean. Live smoke against the primary server (write → read → update → delete → re-delete-NotFoundError) all succeed via the wrapper.

One snag for your CI / re-test. This repo's [tool.uv] exclude-newer = "3 days" filters out 0.7.0 (uploaded today). Two options:

  1. Wait until ~2026-05-02 and the lock will pick up 0.7.0 naturally; OR
  2. Run UV_EXCLUDE_NEWER="0 days" uv lock --upgrade-package memoryhub to refresh apps/adk-py/uv.lock now (this writes exclude-newer-span = "PT0S" into the lock options, which I deliberately did not commit since it relaxes the project's policy).

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.

@rdwj
Copy link
Copy Markdown
Contributor Author

rdwj commented Apr 30, 2026

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:

  • API key — the MEMORYHUB_E2E_API_KEY value I emailed is provisioned on the deployed primary and verified working as the kagenti-ci user (scopes: user, project). No rotation needed.
  • Scoped project — created kagenti-tests on the server, invite_only=true, with kagenti-ci as the only non-admin member. Any other actor attempting a project-scope write to it gets ProjectInviteOnlyError. (Verified with a non-member smoke test.)

One small request that closes the cleanup story cleanly. The current memoryhub-recall example writes with scope="user". That works — user-scope is already isolated by user_id so nothing leaks across tenants — but it makes the cleanup target a bit hand-wavy ("kagenti-ci's user-scope namespace") rather than a single named project we can point an admin at.

If the example switches its store.create(...) to:

result = await store.create(
    f"User mentioned: {query}",
    scope="project",
    project_id="kagenti-tests",
    weight=0.7,
)

then:

  • The invite_only=true boundary on kagenti-tests actually does work for you — only kagenti-ci can write there, hard enforcement.
  • The test target becomes a single named project — easy to point a future admin sweep at if orphans accumulate.

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 list_memories enumeration API on the server yet. I'm filing that as a follow-up on our side; in the meantime orphans accumulate at ~1/crash, manageable.

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.

rdwj added 7 commits May 4, 2026 07:56
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>
rdwj added 7 commits May 4, 2026 07:56
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>
@rdwj rdwj force-pushed the feat/memory-store-protocol branch from 79ad3d2 to d48ed8d Compare May 4, 2026 12:58
@rdwj
Copy link
Copy Markdown
Contributor Author

rdwj commented May 4, 2026

@JanPokorny — settled up on my side. Pushed d48ed8da; here's the delta since your last review:

DCO is green. Eleven commits in the middle of the rework cascade had dropped their Signed-off-by trailers — that's fixed. Whole branch rebased with --signoff, every commit now carries one signoff and DCO reports SUCCESS. (This was the "ACTION_REQUIRED" badge on the PR; we should have caught it before the second pass.)

Example switched to scope="project", project_id="kagenti-tests" (003c25fb). The invite_only=true boundary on kagenti-tests is now actually enforcing on the example, and the test target is a single named project that a future cleanup sweep can point at. memory.mdx updated alongside via embedme so the doc stays in sync.

apps/adk-py/uv.lock regenerated for memoryhub 0.7.0 (d48ed8da). The 3-day exclude-newer window has cleared (0.7.0 was uploaded 2026-04-29; today is 2026-05-04), so uv lock --upgrade-package memoryhub advanced the lock without relaxing exclude-newer-span — project policy intact. All 24 memory-store unit tests pass against 0.7.0 locally, ruff clean.

The empty-string → MemoryRejectionError fix from your last comment is in a69a0b81 + 066e8bbe (rebased SHAs).

Ready for another pass when your queue clears.

@JanPokorny
Copy link
Copy Markdown
Contributor

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

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.

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.

@rdwj
Copy link
Copy Markdown
Contributor Author

rdwj commented May 5, 2026

@JanPokorny — pushed four commits on top of d48ed8da:

6c767502adk-py: Install all extras in test-all task
Root cause of the mise adk-py:test-all --python=3.11 failure. adk-py:setup syncs with --all-extras --dev, but adk-py:test-all ran 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 tests/unit/server/store/test_memory_store.py fails at collection with ModuleNotFoundError: memoryhub on the 3.11 lane. One-word fix: pass --all-extras so test-all matches setup. The only optional extra in the project today is memoryhub, so the surface impact is just installing it in the 3.11 CI venv. All 178 adk-py tests pass locally on 3.11.

2a986079adk-py: Apply ruff-format to memoryhub-related sources
mise check was failing on adk-py:check:ruff-format. Ran mise run adk-py:fix; only line collapses within the 120-column limit, no semantic changes. mise run adk-py:check is green locally.

e7d339dae2e/memoryhub: xfail on backend connectivity / auth drift
Your URL-drift ask. Added _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 __cause__ / __context__ chain because FastMCP's _connect wraps transport errors in a generic RuntimeError, so httpx.ConnectError on a DNS failure only surfaces in __cause__. The four error families that indicate drift — AuthenticationError, ConnectionFailedError, other MemoryHubError, transport-level httpx.HTTPError / OSError — are translated into pytest.xfail with a reason that names the URL and exception. Anything else still bubbles up as a real failure. Guarded with pytest.importorskip(\"memoryhub\") so the rest of the adk-server e2e suite still collects when the extra isn't installed.

5e5dc350adk-server: Regenerate uv.lock for memoryhub extra surface
Transitive consequence of adk-py adding the [memoryhub] extra — uv sync in adk-server now records the matching provides-extras / marker entries. Routine exclude-newer window roll comes along with it; no third-party dep versions change.

Validated locally end-to-end against the deployed primary MemoryHub:

  • Bad host (DNS) → xfail (transport, ConnectError via __cause__)
  • Wrong service at host (405) → xfail (transport, HTTPStatusError)
  • Real URL + bad API key → xfail (auth, AuthenticationError)
  • Real URL + real key → probe passes, full mise run adk-server:test:e2e-examples green for test_memoryhub_recall_example (agent stored + recalled successfully on the cluster)

The example switch (closes #207) is in 003c25fb on this PR.

Two failing CI checksmise adk-server:test:integration and mise adk-server:test:e2e — look like fork-PR secrets. Can you re-run?

rdwj added 4 commits May 5, 2026 09:01
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>
@rdwj rdwj force-pushed the feat/memory-store-protocol branch from 5e5dc35 to dcfc3d9 Compare May 5, 2026 14:01
@rdwj
Copy link
Copy Markdown
Contributor Author

rdwj commented May 6, 2026

The mise check failure is on me — fb2ca189 fixes the two pyrefly errors in docs/development/sdk/memory.mdx (the rejection example and the client-side from_agent_card example). Verified locally with mise run docs:check:pyrefly (0 errors).

The integration and e2e failures are separate. This PR is from my fork (rdwj/adk), and on pull_request events GitHub Actions doesn't expose vars.* or secrets.* to forks. So LLM_MODEL: "${{ vars.OPENAI_MODEL }}" resolves to an empty string, and apps/adk-server/tests/conftest.py:38 crashes on ModelProviderType('') before any test executes. The e2e job is starved of the same creds and times out on the adk-ui rollout.

Could you push this branch to kagenti/adk and re-run CI from there? That's the cleanest unblock — same commits, just under repo context so the secrets resolve.

Separately, I'd like to file a small follow-up to make conftest.py tolerant of empty LLM_MODEL (and/or gate the integration/e2e jobs on head.repo.full_name == github.repository) so the next non-trivial fork PR doesn't hit this. Happy to do that work — let me know which fix you'd prefer.

rdwj added 3 commits May 7, 2026 11:09
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>
@rdwj rdwj force-pushed the feat/memory-store-protocol branch from fb2ca18 to f6fc7e0 Compare May 7, 2026 16:11
@rdwj
Copy link
Copy Markdown
Contributor Author

rdwj commented May 7, 2026

@JanPokorny Pushed three commits and force-updated the branch.

5813de47 (DCO fix) Amend of yesterday's fb2ca189 (pyrefly fix) to add the missing Signed-off-by. DCO is now green.

80028199 (conftest tolerance) Defense-in-depth: when LLM_MODEL is empty, llm_provider_type returns None instead of raising ValueError: '' is not a valid ModelProviderType. The two existing callers (tests/e2e/conftest.py:66, tests/e2e/routes/test_configurations.py:27) only execute under the e2e marker, which is gated below.

f6fc7e03 (workflow gate) Adds head.repo.full_name == github.repository to the if: on adk-server-test-integration and adk-server-test-e2e. On a fork PR these jobs no longer run at all, so the empty-secrets crash on integration and the OIDC-less adk-ui rollout timeout on e2e are both eliminated. adk-server-test-e2e-examples was already gated by the e2e-examples label so I left it untouched. Local mise check is green.

What you should see on the new run for this PR. mise adk-server:test:integration and mise adk-server:test:e2e will be absent from the rollup (gated out), not failing. Everything else (mise check, mise test, mise adk-py:test-all matrix, mise microshift-vm:build, docs-check, security scans, DCO) should stay green as before.

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:

  1. Push feat/memory-store-protocol to kagenti/adk and run CI from there. Same commits, secrets resolve, integration and e2e run for real one last time before merge.
  2. Merge as-is and let the post-merge push: branches: [main] event exercise integration/e2e in main-repo context. If those break post-merge, revert is straightforward since the protocol/example/docs commits are independently valid.

I'd lean toward (1) for cleanest pre-merge signal but either is fine. Local verification on my side: 24/24 tests/unit/server/store/test_memory_store.py pass against memoryhub==0.7.0, ruff/pyrefly clean, and the previously-validated live smoke against the deployed primary still passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants