Skip to content

feat(strands): add Neo4jSessionManager — push-based conversation persistence for Strands agents#136

Merged
Andy2003 merged 35 commits into
neo4j-labs:mainfrom
Andy2003:strands-session-manager
Jul 14, 2026
Merged

feat(strands): add Neo4jSessionManager — push-based conversation persistence for Strands agents#136
Andy2003 merged 35 commits into
neo4j-labs:mainfrom
Andy2003:strands-session-manager

Conversation

@Andy2003

@Andy2003 Andy2003 commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds Neo4jSessionManager, a Strands Agents SessionManager backed by MemoryClient
(self-hosted bolt and hosted NAMS) — the same posture AWS Bedrock AgentCore takes with
AgentCoreMemorySessionManager. The existing Strands integration is pull-based (@tool
functions the agent must decide to call); this adds the push-based half: every turn is
persisted automatically, history is restored on agent restart, and relevant long-term
memories can be injected into each user message.

agent = Agent(
    model="anthropic.claude-sonnet-4-20250514-v1:0",
    tools=nams_context_graph_tools(),
    session_manager=Neo4jSessionManager.for_nams(
        "support-42", retrieval_config=Neo4jRetrievalConfig()
    ),
)

Design spec: docs/superpowers/specs/2026-06-05-strands-session-manager-design.md

What's included

  • Neo4jSessionManager (integrations/strands/session_manager.py) — direct
    SessionManager ABC implementation: lifecycle, hook wiring, write-behind buffer
  • _retrieval.pyNeo4jRetrievalConfig + concurrent long-term search and
    <user_context> block formatting (opt-in per-turn injection)
  • _messages.py — pure Strands ↔ Memory message mapping
  • AsyncBridge (integrations/base.py) — shared persistent-event-loop bridge for
    sync code driving loop-bound async transports
  • Shared NAMS settings-from-env helpers in integrations/strands/config.py
    (deduplicates three previously-identical blocks)
  • Restructured aws-strands.adoc: both modes presented as one integration, combined
    Quick Start, constructor reference, documented limitations
  • Runnable shared-brain example (examples/strands-session-manager/, no API keys needed)

Key design decisions

  • No Strands-specific node types in the graph. A Strands session maps to one
    Conversation; messages map to Message. Persistence is memory-grade: text turns are
    stored (and feed entity extraction / the shared brain); tool-use blocks and
    agent.state are not round-tripped — that's FileSessionManager's job.
  • Write-behind one-slot buffer for redaction. NAMS has no message update/delete
    endpoint; guardrail redaction rewrites the buffer before the original ever reaches
    the backend. sync_agent is a strict no-op because the Strands base class also fires it
    on MessageAddedEvent — flushing there would defeat the redaction window.
  • Injection never contaminates storage. Hooks fire in registration order, so the
    original message is persisted before the in-memory copy is augmented; injection is
    idempotent per message and degrades (warn + skip) on retrieval failures.
  • Backend capability awareness. Structurally-unsupported NAMS calls
    (preference/fact search, message delete) are skipped or warned once, not retried per turn.

Limitations (documented in the guide)

agent.state and conversation-manager window state don't survive restarts; redaction
after a flush falls back to delete-and-re-add on bolt and warns on NAMS; one agent per
manager instance (Graph/Swarm persistence out of scope for v1); searches are
workspace-scoped (user_id scopes writes).

Test plan

  • 53 unit tests (tests/unit/integrations/test_strands_session_manager.py) incl.
    buffer/redaction privacy invariants, hook-ordering, NAMS-mode semantics — green ×2
  • 2 bolt integration tests against Docker Neo4j (persist/restore round-trip,
    redaction never stores the original)
  • 12 example smoke tests; example runs end-to-end without API keys
  • make lint / format clean; zero new mypy errors (253 baseline errors pre-exist on
    main); uvx ty check clean on all branch files
  • 575 docs-syntax tests pass (remaining tests/docs failures pre-exist on main:
    7 need npm build output, 3 are stale-data assertions in unrelated pages)

Andy2003 and others added 28 commits June 5, 2026 09:28
Approved brainstorming output: Neo4jSessionManager/Neo4jSessionRepository
backed by MemoryClient (bolt + NAMS), opt-in long-term retrieval injection,
pure Session-to-Conversation mapping with no Strands-specific node types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hook registration, list formatting)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the Neo4jSessionRepository layer: with no agent-state persistence
and a write-behind buffer, the SessionRepository CRUD contract could not
be honored honestly. Neo4jSessionManager now subclasses the SessionManager
ABC directly; mapping helpers and the async bridge stay private.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified Strands API surface (strands-agents installed):
- Hook imports resolve from strands.hooks directly:
    from strands.hooks import HookRegistry, MessageAddedEvent, AfterInvocationEvent
- MessageAddedEvent IS wired to append_message (and also sync_agent) in base SessionManager.register_hooks
- AfterInvocationEvent calls sync_agent only
- Full register_hooks also covers MultiAgent/BidiAgent lifecycle events

Creates the module skeleton at:
  src/neo4j_agent_memory/integrations/strands/session_manager.py
with Neo4jRetrievalConfig dataclass (all defaults pass the unit test).

Unused scaffolding imports (SessionManager, SessionException, MemoryClient, MemorySettings,
_SESSION_KEY) retained with noqa: F401 / TYPE_CHECKING guards per plan; ruff clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extend the try/except import guard to cover the hooks surface
(AfterInvocationEvent, HookRegistry, MessageAddedEvent) so a missing or
incompatible strands-agents install fails fast at import with the
install hint. Also adds StrandsMessage to the TYPE_CHECKING block and
adds a reason string to pytest.importorskip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… task noise

Extend _AsyncBridge.close() docstring to explain in-flight-call blocking
and the drain-before-close guarantee. Cancel pending asyncio tasks in
test_timeout_raises before stopping the loop to prevent the spurious
"Task was destroyed but it is pending!" stderr line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Appends five pure functions (_message_text, _to_strands_message,
_format_entity, _format_preference, _format_fact) to session_manager.py
and creates tests/unit/integrations/strands_fakes.py with stateful
FakeMemoryClient/FakeShortTerm/FakeLongTerm/FakeReasoning for use by
future task tests. All 10 unit tests pass; ruff clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds Neo4jSessionManager (Strands SessionManager ABC subclass) with
constructor validation, async bridge wiring, and initialize() that
restores conversation history from bolt or NAMS backends. NAMS path
scans conversation metadata to find-or-create by strands_session_id.
Also hardens strands_fakes: NAMS get_conversation raises NamMemoryError
on missing key, add_message raises NamMemoryError for unknown NAMS
conversations, delete_message NAMS branch adds workaround kwarg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, close

Replaces NotImplementedError stubs with the one-slot write-behind buffer
machinery: append_message deep-copies into the buffer and flushes the
previous slot; _flush_buffer persists text turns and raises
SessionException on failure; sync_agent is a strict no-op; close()
flushes then conditionally closes only the client we connected, then
stops the bridge. Tool-call mirroring to reasoning memory is opt-in
(record_tool_calls=True) and warns-only on failure. Adds cast() fix for
_to_strands_message Literal type narrowing. 28 tests pass (12 new + 1
un-xfailed seeding test + 15 prior).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ge gaps

- Clear `_last_persisted` to None before re-raising in the bolt late-redaction
  error path so a stale message id is never reused after a failed delete.
- Assert `extract_entities=False` on the rewrite call in
  `test_late_redaction_on_bolt_deletes_and_rewrites`.
- Add `test_redact_with_nothing_stored_warns_and_returns` to cover the
  no-op path when neither buffer nor persisted message is present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ction slots

Override `register_hooks` to call super() first (base wires initialize,
append_message, sync_agent), then register our own `AfterInvocationEvent`
callback that flushes the write-behind buffer. When a `Neo4jRetrievalConfig`
is present, also register a `MessageAddedEvent` callback that will call
`_inject_context` (stub for Task 9). Remove all stale `# noqa: F401`
comments from the import block now that every name is genuinely referenced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements _inject_context and _aretrieve on Neo4jSessionManager so that
user messages are prepended in-memory with relevant entities, preferences,
and facts from long-term storage when retrieval_config is set.  Individual
search failures degrade gracefully via asyncio.gather(return_exceptions=True);
bridge-level failures log a WARNING and leave the turn intact.  Also fixes
the register_hooks docstring to accurately describe AfterInvocationEvent
callback ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ency guard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds Neo4jSessionManager and Neo4jRetrievalConfig to the public strands
integration __init__.py. Also strengthens the _inject_context idempotency
guard to use the full library-controlled header prefix rather than the bare
tag, preventing false matches on user text. Reformats to ruff standards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…fires

Phase 1.5 directly seeds an ORGANIZATION entity and a compliance
preference via MemoryClient, giving the demo something to retrieve.
The <user_context> injection block now reliably appears in Phase 2
output, making the shared-brain headline feature visible without
any LLM or extraction API key. README expected-output updated to
match, including the new seeding print line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l injection; docs corrections

NAMS raises NotSupportedError on search_preferences/search_facts, so the
default retrieval config logged a WARNING on every user turn. Guard both
conditions with `not self._client.is_nams` so those searches are never
dispatched on the NAMS backend (entity search still works). Adds a unit
test that hard-fails if either unsupported search is called on NAMS.

Docs: add NOTE block in the Retrieval Injection section; fix the
sync_agent Limitations clause (it is a no-op, not a flush trigger) in
both the how-to and the design spec. CHANGELOG: change colon to em-dash
to match sibling entry style.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… example session reset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- pass user scoping + explicit limit to NAMS conversation resolution
- accurate AfterInvocationEvent ordering docs (user hooks see unpersisted turn)
- drop silently-swallowed **kwargs from constructor (typos now TypeError)
- idempotency guard before retrieval (no wasted/contaminated re-search)
- centralize backend capabilities (_BackendCapabilities) over per-site is_nams
- _format_entity prefers Entity.full_type (matches get_context format)
- split key resolution from history load; add restore_limit option
- merge _owns_client/_we_connected into _should_close_client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…idge to integrations.base, close docs-import validation hole

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- replace _BackendCapabilities dataclass+cache with one _is_nams property
- dedupe the add_message call into _store_message (flush + redact share it)
- single target-block lookup in _inject_context (was two identical loops)
- table-driven _aretrieve (was three copy-pasted search blocks)
- _ensure_session: one expression, drops the python -O-unsafe assert

Behavior identical; 53 unit + 2 integration tests unchanged and green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
session_manager.py keeps only the SessionManager protocol implementation
(lifecycle, hooks, write-behind buffer). Pure message mapping moves to
_messages.py; Neo4jRetrievalConfig + search/formatting move to
_retrieval.py (decoupled from session state — takes long_term + config).
Mirrors the mcp package's _tools.py/_resources.py convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- hook callbacks as typed bound methods (HookCallback[TEvent] protocol
  matches param name 'event'; lambdas inferred as Unknown)
- transport_mode typed as the nams TransportMode Literal in
  build_nams_settings and for_nams
- rename module-level import-guard 'e' to stop shadowing inner handlers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stop appending the session manager as an afterthought: the intro now
presents pull-based tools and push-based session manager as the two
modes of one integration, the Quick Start shows them combined on one
Agent, and the page is organized into two co-equal sections with the
cross-cutting topics (multi-user, shared-brain) spanning both. No new
content claims; existing snippets preserved verbatim where unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 5, 2026

Copy link
Copy Markdown

@Andy2003 is attempting to deploy a commit to the lyonwj's projects Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Jun 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agent-memory Ready Ready Preview, Comment Jul 10, 2026 3:41pm

Request Review

Andy2003 and others added 3 commits June 19, 2026 13:48
The Strands integration leaned on `Any`/`list[Any]` and untyped override
signatures even though the framework supplies precise types — so editors
and type checkers could not see what was being passed. Adopt the real
types throughout:

- session_manager: override signatures use Strands `Agent` / `Message`
  (matching the base class), tool blocks are `list[ToolUse]`, internal
  state is typed (`MemoryClient`, `Message | None`, `UUID | None`), and
  `__exit__` uses the proper context-manager types. The NAMS-only
  `_aresolve_conversation` branch localizes the bolt/NAMS short-term
  polymorphism behind one annotated boundary instead of an `Any` client.
- AsyncBridge.run is generic (`Coroutine[Any, Any, T] -> T`), so every
  `self._bridge.run(...)` call propagates the awaited value's real type.
- _messages / _retrieval: typed against the memory models (`Message`,
  `Entity`, `Preference`, `Fact`, `LongTermMemory`); the per-type
  formatters are precisely typed.
- tools: `_run_async` is generic, factories return `AgentTool`, the
  public `*_context_tools` return `list[AgentTool]`, and `transport_mode`
  is the `TransportMode` literal (fixes a real arg-type mismatch into
  `build_nams_settings`).
- __init__: `llm_provider_from_strands` returns `LLMProvider`.

The few remaining `Any`s are legitimate framework boundaries (`**kwargs`
forwarding, arbitrary-JSON tool inputs/results, a heterogeneous dispatch
table). Test fakes updated to model a real `Entity` (which always has the
`full_type` property). mypy clean on the integration; the pre-existing
`run_sync` typing debt in integrations/base.py is left for a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Running `ty` over the typed integration surfaced real issues that the
previous `Any`-heavy signatures had hidden:

- **Bug:** `context_graph_tools` built a bolt client via
  `Neo4jConfig(user=..., password=<str>)`, but the field is `username`
  and `password` is a `SecretStr`, and the model forbids extras — so the
  direct-Neo4j tool path would raise a ValidationError at runtime. Fixed
  to `username=` + `SecretStr(...)`.
- The `**config` dict unpacking collapsed per-argument types to
  `str | None`; pass the factory arguments explicitly so the narrowed
  `str` values (uri/password) survive.
- `_aresolve_key` / cast narrowing: resolve the conversation key through
  a local so it narrows to `str`; reinterpret the NAMS short-term via
  `object` for the deliberate cross-hierarchy cast to `ShortTermProtocol`.
- `_run_async` wraps `asyncio.run` in a zero-arg lambda so the result
  type flows through `Executor.submit`.

`ty` and `mypy` are now clean on `integrations/strands/`. The remaining
`run_sync` errors in `integrations/base.py` need a ParamSpec signature
change to a shared helper used by other integrations — deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cast through object is the checker-sanctioned form for a deliberate
cross-type cast; clarify in the comment that it is a stopgap until
MemoryClient.short_term is widened to ShortTermProtocol (deferred
follow-up), not an oversight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Andy2003

Copy link
Copy Markdown
Collaborator Author

The Strands integration here is ty- and mypy-clean. One deliberate cross-type cast remains in session_manager.py (_aresolve_conversation): MemoryClient.short_term is statically the concrete bolt ShortTermMemory, but list_conversations/create_conversation live on ShortTermProtocol (implemented by the NAMS backend). The proper fix — widening the property to ShortTermProtocol and removing two existing # type: ignores in __init__.py — is a core/public-API change, tracked separately in #143. That issue also captures the run_sync ParamSpec fix and the strands-extra checker config.

@Andy2003
Andy2003 marked this pull request as ready for review June 19, 2026 12:36
Comment thread docs/superpowers/specs/2026-06-05-strands-session-manager-design.md

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a push-based Strands Agents integration by introducing Neo4jSessionManager, enabling automatic conversation persistence/restore backed by neo4j-agent-memory (bolt or hosted NAMS) and optional per-turn long-term memory injection (Neo4jRetrievalConfig). It complements the existing pull-based Strands @tool integration and documents both modes together.

Changes:

  • Add Neo4jSessionManager with write-behind buffering (for redaction), lifecycle/hook wiring, optional tool-call mirroring, and a NAMS factory constructor.
  • Add retrieval/context injection (_retrieval.py) and message mapping helpers (_messages.py), plus a shared persistent async↔sync bridge (AsyncBridge).
  • Add extensive unit/integration/example/doc tests and update Strands documentation and changelog.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/unit/integrations/test_strands_session_manager.py Comprehensive unit coverage for buffering/redaction, hook ordering, NAMS semantics, injection idempotency, and bridge behavior.
tests/unit/integrations/strands_fakes.py Stateful fake MemoryClient surfaces used by the session-manager unit tests.
tests/integration/test_strands_session_manager.py Bolt-backed integration tests for persist/restore and redaction invariants.
tests/examples/test_strands_session_manager_example.py Smoke tests ensuring the runnable example structure, imports, and key calls remain intact.
tests/docs/test_code_snippets.py Ensures new Strands integration exports are referenced and importable when Strands is installed.
src/neo4j_agent_memory/integrations/strands/tools.py Tighten typing and deduplicate NAMS settings/env resolution via shared helpers.
src/neo4j_agent_memory/integrations/strands/session_manager.py New Neo4jSessionManager implementation: persistence, buffering/redaction, injection, and NAMS resolution logic.
src/neo4j_agent_memory/integrations/strands/config.py Add shared NAMS connection/settings helpers used by both tools and session manager.
src/neo4j_agent_memory/integrations/strands/_retrieval.py New retrieval config + concurrent long-term searches + context block formatting.
src/neo4j_agent_memory/integrations/strands/_messages.py Pure Strands ↔ stored message mapping helpers.
src/neo4j_agent_memory/integrations/strands/init.py Export session manager + retrieval config and tighten llm_provider_from_strands typing.
src/neo4j_agent_memory/integrations/base.py Add AsyncBridge for persistent background event loop bridging.
examples/strands-session-manager/README.md Document the shared-brain demo and how to use the session manager (bolt + NAMS).
examples/strands-session-manager/main.py Runnable three-phase demo (persist, seed LTM, inject, restore) without API keys.
docs/superpowers/specs/2026-06-05-strands-session-manager-design.md Design spec capturing architecture, semantics, and constraints (bolt vs NAMS).
docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc Restructure docs to present tools + session manager as complementary modes; add quick starts and limitations.
CHANGELOG.md Add release notes entry for the new Strands session manager capabilities.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/neo4j_agent_memory/integrations/base.py Outdated
Comment thread src/neo4j_agent_memory/integrations/strands/session_manager.py Outdated
Comment thread tests/unit/integrations/strands_fakes.py Outdated
Andy2003 and others added 3 commits July 14, 2026 13:06
# Conflicts:
#	src/neo4j_agent_memory/integrations/base.py
#	src/neo4j_agent_memory/integrations/strands/tools.py
- AsyncBridge.close(): skip loop.close() when the join times out (closing a
  still-running loop raises RuntimeError); always reset references so the
  bridge stays reusable instead of half-closed.
- session_manager.register_hooks: correct the AfterInvocationEvent ordering
  docstring — under reverse dispatch, user hooks registered after this manager
  run BEFORE our flush (that is why they observe the un-persisted turn).
- strands_fakes.FakeShortTerm.add_message: drop extra kwargs in NAMS mode to
  match the real backend (accepts only {content, role}), so NAMS-mode tests
  can't lean on kwargs the server silently discards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-merge ruff (from main's tooling bump) collapses a SimpleNamespace call
onto one line. Format-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Andy2003
Andy2003 merged commit 4529813 into neo4j-labs:main Jul 14, 2026
18 of 19 checks passed
@Andy2003
Andy2003 deleted the strands-session-manager branch July 14, 2026 12:18
Andy2003 added a commit that referenced this pull request Jul 16, 2026
…loaded)

Merging main brought in #136's integrations/strands/__init__.py, whose
`cast("LLMProvider", from_provider(model_id))` became redundant now that
Phase 5 made `from_provider` @overload-typed (kind="llm" -> LLMProvider).
mypy [redundant-cast] and ty flagged it in the PR-merge type-check. Drop
the cast; the _passthrough cast (narrows Any) stays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants