refactor(memory): refactor TestMemoryStore to use the unified storage interface - #3260
Conversation
|
Assessment: Comment Clean, well-motivated refactor — moving Review themes
Nice work dropping the load-memoization complexity and improving browser-bundle safety with the static |
9ee577e to
6f46a1f
Compare
61e0ba8 to
e7f6484
Compare
Re-review (latest push, merge commit
|
|
Docs haven't yet been released, the TestMemoryStore was released last week. So breaking change seems acceptable to me. |
Re-review (updated after force-push)Assessment: Comment — the two substantive concerns from my first pass are addressed in code. One follow-up (test coverage) and one process item remain, neither blocking. Status of prior feedback
Housekeeping: my earlier tooling retried and created duplicate inline threads on lines 149/150. I lack permission to delete/edit them, so I've replied marking the extra copies as duplicates — please disregard those. Nice, focused iteration on the namespacing fix. |
|
@mkmeral 👋 sorry for the wait — here's the consolidated public API change breakdown for bar raising. This PR now lands both SDKs (Python + TypeScript) and the surfaces are symmetric. Note: my earlier
|
| Before | After | |
|---|---|---|
persist?: boolean / persist: bool |
✅ (default true) |
❌ removed |
path?: string / path: str |
✅ | ❌ removed |
storage?: Storage |
❌ | ✅ new (optional; defaults to InMemoryStorage) |
persist=false → pass storage: new InMemoryStorage(); path=x → pass storage: new LocalFileStorage(x).
2. Behavior change: default persistence flipped (⚠️ silent data-loss on upgrade)
- Before: persisted to
~/.strands/memory/<name>.jsonby default — durable across restarts. - After: ephemeral by default (
InMemoryStorage) — entries lost on process exit, no error/warning. - The most common call site
TestMemoryStore({ name })keeps compiling but silently stops persisting. This was flagged earlier and is now documented in the docstring; still worth a loud release-note callout.
3. Second, subtler location change (worth explicit bar-raiser attention)
Even after migrating to storage: new LocalFileStorage(), the default directory moves ~/.strands/ (home) → ./.strands/ (cwd) per the new docstrings. So a user doing the "obvious" 1:1 migration to keep persistence will not find their old data (different absolute path + memory/ namespace subdir). This is a second migration gotcha layered on top of #2 — recommend calling it out explicitly in migration notes.
4. Failure-mode / exception-contract changes
- Python
add/search: previously raisedOSError(with target path) on disk read/write failure → now raisesStorageErrorfrom the backend.ValueErrorstill covers malformed blobs, and now additionally validatesmetadatashape. - TypeScript
search/add: contract now documents@throws StorageErrorfor backend read/write failures — a new exception type callers must account for. - Constructor: the old
path must not be emptyvalidation is gone (param removed).
5. New composition behavior — auto-namespacing
- Backend is auto-scoped under a
memory/prefix unless the caller already passed a namespaced view (NAMESPACED in backend), so a pre-namespaced backend won't get double-prefixed (memory/memory/...). Consistent withSessionManager/offloader convention. - Record blob key:
memory/<sanitized-name>.json. On-disk record format unchanged (camelCaseid/content/metadata/createdAt, Z-suffixed ms timestamps), so cross-SDK read compat holds.
6. Concurrency model change (Python, behavioral)
- Switched from
threading.Lock→ per-event-loopasyncio.Lock(rebinds per running loop to support a store reused across the fresh loops a syncAgentspins up). - Dropped the in-memory cache + load memoization: every
search/addreads fresh from the backend. Trade-off: eliminates the cold-load race by construction, at the cost of a backend read per operation. Prior cross-OS-thread guarantees are replaced by per-loop async serialization.
Open questions for bar raising
- Backwards-compat shim vs. clean break. @opieter-aws proposed keeping
persist/pathas@deprecatedaliases. Given (a) docs not yet released and (b) zero GitHub/internal usage, a clean break is defensible — but the deprecation shim is cheap insurance against the two silent behavior changes above (ci: update mypy requirement from <1.0.0,>=0.981 to >=0.981,<2.0.0 #2 + ci: update sphinx-rtd-theme requirement from <2.0.0,>=1.0.0 to >=1.0.0,<4.0.0 #3). Worth a deliberate decision rather than defaulting to either. - Is ephemeral-by-default the right "happy path"? The tenets caution against the obvious path silently doing the surprising thing. For a store literally named to persist "memory," a new user's mental model may expect durability. Confirm ephemeral-default was weighed against a persistent default (or a required explicit
storagearg to force the choice). - Should
storagebe required rather than optional-with-ephemeral-default? Making it required would force callers to consciously choose durability vs. ephemerality and eliminate both silent-data-loss vectors — at the cost of ergonomics for the throwaway/prototype case.
Everything is internally consistent and well-tested across both SDKs; these are design-intent confirmations for the bar-raiser, not correctness gaps.
|
What if |
|
@pgrayy that's the crux of the bar-raising decision, so let me lay out the tradeoff both ways rather than pick a side. Your framing (keep
What that gives up — i.e., "does it need anything other than
There's also a consistency dimension: Possible middle ground worth weighing: keep an ergonomic persistence default (so the common call site doesn't silently lose data) and accept an optional Net: if the team's read is that this store will only ever realistically be local-file or in-memory, your under-the-hood approach is the lower-risk call. If browser/community backends are in scope (the TS browser-safety work suggests they are), exposing |
|
@pgrayy good question to pressure-test the whole approach — I think it splits into two parts: "Does "Keep the same constructor args but build The hybrid worth considering (close to @opieter-aws's earlier sketch, minus the deprecation): accept new TestMemoryStore({ name }) // persists to ~/.strands/memory (unchanged — no data loss)
new TestMemoryStore({ name, persist: false }) // ephemeral
new TestMemoryStore({ name, path: '/data/notes.json' })// custom file
new TestMemoryStore({ name, storage: new S3Storage() })// any backendThat gets backwards-compat + zero silent migration surprises + full extensibility. The price is a wider config surface and a precedence rule to document (what happens if both Net: the pure "constructor-args-only, LocalFileStorage under the hood" option is the simplest but forfeits the browser story that partly motivated the migration; the hybrid keeps that story while erasing the breaking-change concerns. Given docs aren't released and usage is ~zero, the clean |
|
@strandly-the-agent review latest implementation |
Re-review of the latest implementationThis pivot is a clean resolution — nicely done. The store now runs on the unified
One actionable item (Important) — PR metadata is now staleThe code is no longer a breaking change, but the PR still advertises one:
Please update the title (drop the Minor / optional
Assessment: Approve (pending the metadata cleanup). The implementation is correct, backwards-compatible, well-tested, and preserves the guarantees of the original. This ended up in a much better place than a breaking change — good call incorporating the backwards-compatible route. |
Description
TestMemoryStorehand-rolled its own filesystem persistence in both SDKs — path resolution, atomic temp-file writes, plus a records cache with load memoization to paper over cold-load races. The unifiedStorageinterface exists precisely to delete this per-subsystem re-invention, and the storage design doc names the memory store as a consumer to migrate. This moves persistence onto aStoragebackend internally.The public config is unchanged:
persist/pathand the~/.strands/memory/<name>.jsondefault behave exactly as before. Internally the store now resolves a backend from that config —persist: false→InMemoryStorage, otherwise aLocalFileStoragerooted so an explicitpathstill writes to that exact file and the default still lands at the home-dir location. Dropping the bespoke cache also let the read-modify-write inaddread fresh under a lock, closing the cold-load race by construction rather than guarding against it.Related Issues
#3099
#3253
Documentation PR
N/A
Type of Change
Other (please describe): internal refactor — no public API or behavior change
Testing
Ran the
TestMemoryStoreunit suites in both SDKs (Python pytest, TS Node), plus type-check, lint, and format for each; all pass. Exercised each store end to end against both backends, confirming the default home-dir path, an explicitpath,persist: falseephemerality, dedup, and cross-loop reuse from a synchronous agent all behave as before.hatch run prepareChecklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.