Skip to content

refactor(memory): refactor TestMemoryStore to use the unified storage interface#3260

Open
opieter-aws wants to merge 9 commits into
strands-agents:mainfrom
opieter-aws:opieter-aws/rebuild-testmemorystory-ts
Open

refactor(memory): refactor TestMemoryStore to use the unified storage interface#3260
opieter-aws wants to merge 9 commits into
strands-agents:mainfrom
opieter-aws:opieter-aws/rebuild-testmemorystory-ts

Conversation

@opieter-aws

@opieter-aws opieter-aws commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Description

TestMemoryStore hand-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 unified Storage interface 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 a Storage backend internally.

The public config is unchanged: persist/path and the ~/.strands/memory/<name>.json default behave exactly as before. Internally the store now resolves a backend from that config — persist: falseInMemoryStorage, otherwise a LocalFileStorage rooted so an explicit path still 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 in add read 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 TestMemoryStore unit 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 explicit path, persist: false ephemerality, dedup, and cross-loop reuse from a synchronous agent all behave as before.

  • I ran hatch run prepare

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@github-actions github-actions Bot added size/m area-community Related to community and contributor health chore Maintenance tasks, dependency updates, CI changes, refactoring with no user-facing impact labels Jul 14, 2026
@opieter-aws
opieter-aws marked this pull request as ready for review July 14, 2026 20:01
@opieter-aws
opieter-aws requested a review from a team as a code owner July 14, 2026 20:01
@opieter-aws
opieter-aws requested a review from lizradway July 14, 2026 20:01
@opieter-aws opieter-aws linked an issue Jul 14, 2026 that may be closed by this pull request
Comment thread strands-ts/src/vended-memory-stores/test-memory-store/store.ts Outdated
Comment thread strands-ts/src/vended-memory-stores/test-memory-store/store.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Assessment: Comment

Clean, well-motivated refactor — moving TestMemoryStore onto the unified Storage interface deletes a good amount of bespoke node:fs code and eliminates the cold-load race by construction rather than papering over it. The PR description and tests are strong. My feedback is about process/consistency around the public-API change rather than the implementation itself.

Review themes
  • API review process: This changes a public constructor contract and flips the default persistence behavior, yet it's labeled chore ("no user-facing impact") and carries no api/needs-review label. Per team/API_BAR_RAISING.md this is at least a moderate change to a customer-facing contract and should go through API review. (The description already contains the before/after material a reviewer needs — good.)
  • Behavior change safety: Ephemeral-by-default silently drops persistence for existing new TestMemoryStore({ name }) callers on upgrade (inline comment). Needs loud migration/changelog notes.
  • Composability: The memory/ prefix is hardcoded into the key rather than using the NAMESPACED/namespace() pattern the session subsystem uses (inline comment).
  • Docs: Public API changed (path/persist removed) but docs/examples and a changelog entry aren't updated in this PR.

Nice work dropping the load-memoization complexity and improving browser-bundle safety with the static InMemoryStorage import.

@opieter-aws
opieter-aws force-pushed the opieter-aws/rebuild-testmemorystory-ts branch from 9ee577e to 6f46a1f Compare July 14, 2026 20:22
Comment thread strands-ts/src/vended-memory-stores/test-memory-store/store.ts Outdated
Comment thread strands-ts/src/vended-memory-stores/test-memory-store/store.ts Outdated
Comment thread strands-ts/src/vended-memory-stores/test-memory-store/store.ts Outdated
Comment thread strands-ts/src/vended-memory-stores/test-memory-store/store.ts Outdated
@opieter-aws
opieter-aws force-pushed the opieter-aws/rebuild-testmemorystory-ts branch from 61e0ba8 to e7f6484 Compare July 14, 2026 20:34
@github-actions

Copy link
Copy Markdown
Contributor

Re-review (latest push, merge commit 61e0ba8)

Updated assessment: Approve (non-blocking follow-ups below).

Both of my earlier inline concerns are addressed in the latest push:

  • Namespacing — the constructor now uses this._storage = NAMESPACED in backend ? backend : namespace(backend, 'memory') and drops the memory/ prefix from _key, exactly matching the SessionManager convention. A caller-supplied pre-namespaced view is no longer double-prefixed.
  • Ephemeral default — the class docstring now states plainly that the store "defaults to an ephemeral InMemoryStorage: entries are lost when the process exits", and the example shows the opt-in LocalFileStorage path.

Remaining follow-ups (both minor, non-blocking)

  1. Test the new NAMESPACED in backend branch. The double-prefix guard added in response to my feedback is currently untested — no test passes a pre-namespaced view. Suggest adding a case asserting that new TestMemoryStore({ name: 'notes', storage: someStorage.namespace('memory') }) writes to memory/notes.json (not memory/memory/notes.json), i.e. it behaves identically to passing the raw backend. This locks in the exact edge case the branch exists for. The suite is otherwise strong (33 tests: ephemeral default, persistence/restart, concurrency, corruption paths, cross-SDK interop).
  2. Release-note the breaking change. There's no TS CHANGELOG in the repo, so please make sure the "data no longer persists by default — pass a Storage backend" note lands in the release notes for upgraders. The ! in the title covers semver; this is about a human-readable heads-up.
  3. API-review label. This changes a public constructor contract and default behavior; per team/API_BAR_RAISING.md it warrants the api/needs-review label rather than the chore classification.

⚠️ Housekeeping — duplicate inline threads

Apologies: a retry issue in my tooling caused the two inline threads to be posted three times each (lines 149 and 150, at 20:05 / 20:24 / 20:25). They're identical in content, and I don't have permission to delete or edit them. Please disregard the extra copies — each pair represents a single comment, both of which are now resolved by the changes above.

@opieter-aws

Copy link
Copy Markdown
Contributor Author

Docs haven't yet been released, the TestMemoryStore was released last week. So breaking change seems acceptable to me.

@opieter-aws opieter-aws added api/needs-review Makes changes to the public API surface and removed area-community Related to community and contributor health labels Jul 14, 2026
const backend = storage ?? new InMemoryStorage()
this._storage = NAMESPACED in backend ? backend : namespace(backend, 'memory')
this._key = `${sanitizeName(name)}.json`
}

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.

Issue: The new NAMESPACED in backend ? backend : namespace(backend, 'memory') guard is the right fix, but its "already-namespaced" arm is untested. The 33-test suite always passes a bare InMemoryStorage/LocalFileStorage, so only the else branch (auto-namespacing) is exercised. The double-prefix regression this guard prevents could silently come back.

Suggestion: Add one test that passes a pre-namespaced view and asserts no double-prefixing, e.g.:

it('does not double-prefix an already-namespaced backend', async () => {
  const backend = new LocalFileStorage(dir)
  const store = new TestMemoryStore({ name: 'notes', storage: backend.namespace('memory') })
  await store.add('hi')
  // written under memory/notes.json, NOT memory/memory/notes.json
  expect(await backend.read('memory/notes.json')).toBeDefined()
  expect(await backend.read('memory/memory/notes.json')).toBeUndefined()
})

@github-actions

Copy link
Copy Markdown
Contributor

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
  • Composability / namespacing — resolved. Constructor now uses NAMESPACED in backend ? backend : namespace(backend, 'memory') and drops the hardcoded prefix from _key, matching the SessionManager convention. A pre-namespaced view is no longer double-prefixed.
  • Ephemeral-default clarity — the class docstring now states entries are ephemeral and lost on process exit, with the opt-in persistence path shown in the example. Remaining nit: make sure the "no longer persists by default" note lands in release notes (no TS CHANGELOG in-repo).
  • 🆕 Test gap — the new "already-namespaced" branch of the guard is untested; all 33 tests pass a bare backend. Added an inline suggestion with a concrete test.
  • API review process — still labeled chore; this changes a public constructor contract and default behavior, so per team/API_BAR_RAISING.md it should carry api/needs-review. Please confirm with a designated API reviewer.

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.

lizradway
lizradway previously approved these changes Jul 16, 2026
@mkmeral

mkmeral commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

@strandly-the-agent don't you like me? where's my response? 😢

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

why are we making a breaking change? can't we solve this another way?

@strandly-the-agent strandly-the-agent 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.

🔴 Latest implementation: changes requested — one Python data-loss blocker remains at 541fb5d5.
✅ Exact head 541fb5d5e23118511982b7db279359b3e109298b matches the remote; git diff --check and worktree are clean.
✅ Focused verification: Python 42/42; TypeScript 36/36, no type errors.
✅ The prior sequential two-asyncio.run() failure is fixed: 4/4 records persist. Public persist/path, the persistent default, and prior file locations are restored.
🔴 Simultaneous threads/loops still return two successful add() IDs while persisting one record: repro and fix guidance.
🟡 An invalid explicit path can make nonempty search return [] instead of surfacing the configuration error: details.
API: no exported shape changed, but concurrency/error/fresh-read contracts did. Keep api/needs-review; no API meeting is needed.

Latest delta and observable behavior

Restored / unchanged

  • Python and TypeScript retain persist/path; there is no public storage parameter.
  • Default persistence remains ~/.strands/memory/<sanitized-name>.json; explicit path still selects that file; persist=false remains per-instance ephemeral.
  • Exports, search/add signatures, result types, ranking, deduplication, record fields, and normal data locations are unchanged.
  • The prior eager-loop-bound lock finding is resolved and regression-tested. Its old inline thread is obsolete/outdated; this review does not re-raise it.

Remaining observable deltas from the internal Storage refactor

  • Every token-bearing search and every valid add rereads storage instead of using the prior instance cache, so external edits/deletes/corruption become visible.
  • persist=false now JSON-serializes records, so unsupported Python metadata can fail where the prior in-memory list accepted it.
  • Backend failures now surface as StorageError and generally name the key/basename rather than the old full-path OSError/generic Error message.
  • Malformed non-object metadata is rejected eagerly.
  • LocalFileStorage retains atomic temp-file replacement for individual writes, but the Python instance read-modify-write transaction is not serialized across simultaneous loops.

CI snapshot

Functional checks completed so far are green across Linux Python 3.10–3.14, most Windows/macOS Python jobs, TypeScript Node 20/22/24 Linux/macOS, lint, code quality, audit, package install, and patch coverage. Some Windows/docs/integration jobs were still running at review time; the aggregate failure was the expected API-label policy gate.

Routed review passes
  • Correctness / adversarial: sequential-loop reuse now passes. A deterministic simultaneous two-thread/two-loop barrier repro returns two IDs but retains one record; merge-base serialized this same-instance case.
  • API / DevX: public shape/default/location compatibility is restored. The PR body’s “no behavior change” wording is broader than the actual fresh-read, error, validation, serialization, and concurrency deltas.
  • Test quality: the new yielding sequential-loop regression is strong. The missing simultaneous-loop barrier test directly hides the remaining blocker; lower-value filename/ephemeral/TS-recovery gaps were suppressed.
  • Docs accuracy: prior breaking-change and namespacing feedback is stale and not repeated. Remaining wording nits were suppressed behind the concrete concurrency and invalid-path findings.
  • Model context: this remains agent-visible: concurrent synchronous invocations can receive successful memory IDs for records that were overwritten, while ENOTDIR currently looks like “no memories.”

@opieter-aws

Copy link
Copy Markdown
Contributor Author

We could keep the store backwards compatible with this interface

export interface TestMemoryStoreConfig extends MemoryStoreConfig {
  /** Storage backend to persist through. Preferred over path/persist. */
  storage?: Storage
  /** @deprecated Pass `storage: new InMemoryStorage()` instead. */
  persist?: boolean
  /** @deprecated Pass `storage: new LocalFileStorage(dir)` instead. */
  path?: string
}

But given that:

  1. No docs yet released
  2. Zero hits on Github for usage, zero internal

I think we can still do a breaking change here.

@github-actions

Copy link
Copy Markdown
Contributor

@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 _read metadata-validation finding is now resolved (Python validates metadata shape), and the namespace() threads are resolved (TS uses the public helper).

TestMemoryStore — Public API Changes

1. Constructor config (BREAKING — removed + added params)

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>.json by 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 raised OSError (with target path) on disk read/write failure → now raises StorageError from the backend. ValueError still covers malformed blobs, and now additionally validates metadata shape.
  • TypeScript search/add: contract now documents @throws StorageError for backend read/write failures — a new exception type callers must account for.
  • Constructor: the old path must not be empty validation 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 with SessionManager/offloader convention.
  • Record blob key: memory/<sanitized-name>.json. On-disk record format unchanged (camelCase id/content/metadata/createdAt, Z-suffixed ms timestamps), so cross-SDK read compat holds.

6. Concurrency model change (Python, behavioral)

  • Switched from threading.Lockper-event-loop asyncio.Lock (rebinds per running loop to support a store reused across the fresh loops a sync Agent spins up).
  • Dropped the in-memory cache + load memoization: every search/add reads 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

  1. Backwards-compat shim vs. clean break. @opieter-aws proposed keeping persist/path as @deprecated aliases. 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.
  2. 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 storage arg to force the choice).
  3. Should storage be 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.

@pgrayy

pgrayy commented Jul 20, 2026

Copy link
Copy Markdown
Member

What if TestMemoryStore kept the same constructor arguments but initialized LocalFileStorage for you under the hood? Does TestMemoryStore need anything other than LocalFileStorage?

@github-actions github-actions Bot added size/m and removed size/l labels Jul 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

@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 path/persist, wrap LocalFileStorage internally):

What that gives up — i.e., "does it need anything other than LocalFileStorage?": I think the honest answer is yes, in two concrete cases, not just speculative generality:

  1. Browser. The TS store went out of its way to stay browser-bundle-safe (the old code used dynamic node:os/node:path imports precisely so it wouldn't break the bundle). LocalFileStorage doesn't exist in the browser. Accepting a Storage lets a browser user pass an in-memory or future IndexedDB backend cleanly; hardcoding LocalFileStorage under the hood reintroduces the exact node-only coupling this design was trying to shed.
  2. Tests / ephemeral. The in-memory backend is a first-class need (it's the new default). Under a path/persist API you'd re-grow a persist=false branch, which is the bespoke conditional the unified interface is meant to collapse.

There's also a consistency dimension: SessionManager and the context offloader both accept a Storage. If TestMemoryStore instead hides it behind path/persist, it becomes the odd consumer out, and the "compose any backend" story gets an asterisk.

Possible middle ground worth weighing: keep an ergonomic persistence default (so the common call site doesn't silently lose data) and accept an optional storage for the browser/S3/in-memory cases — i.e., don't force everyone through new LocalFileStorage(), but don't hide the abstraction either. That costs a slightly wider config surface.

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 Storage is the consistent choice — and then the open question collapses to just "persistent-or-ephemeral default?" (open Q2). This is squarely a bar-raiser call; flagging the inputs rather than deciding it.

@github-actions

Copy link
Copy Markdown
Contributor

@pgrayy good question to pressure-test the whole approach — I think it splits into two parts:

"Does TestMemoryStore need anything other than LocalFileStorage?" — Yes, and the strongest driver is the browser bundle. LocalFileStorage pulls in node:fs/node:os/node:path; the old store went out of its way to await import('node:os') dynamically precisely so the module stayed browser-bundle-safe. A browser consumer can't use LocalFileStorage at all — they need InMemoryStorage today or an IndexedDB backend later. On top of that: in-memory for throwaway/test stores, and S3 for a store shared across instances. So the Storage seam earns its keep beyond just local files — this is the #3099 motivation.

"Keep the same constructor args but build LocalFileStorage under the hood?" — Viable and it does fix the two silent behavior changes I flagged (ephemeral-by-default data loss + the ~/.strands./.strands move), because persist=true default would keep persisting exactly where it did. The cost is that it re-couples the store to node:fs and gives up the browser/S3/in-memory flexibility above — unless you keep both.

The hybrid worth considering (close to @opieter-aws's earlier sketch, minus the deprecation): accept storage?: Storage for advanced backends and keep path/persist as node conveniences that construct a LocalFileStorage internally, with storage taking precedence.

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 backend

That 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 path and storage are passed — presumably storage wins, or throw).

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 storage-only break is also still defensible — this is really a "how much do we value backwards-compat vs. a minimal surface" call for you all.

@opieter-aws

Copy link
Copy Markdown
Contributor Author

@strandly-the-agent review latest implementation

@opieter-aws opieter-aws changed the title refactor(memory)!: refactor TestMemoryStore to use the unified storage interface refactor(memory): refactor TestMemoryStore to use the unified storage interface Jul 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Re-review of the latest implementation

This pivot is a clean resolution — nicely done. The store now runs on the unified Storage interface internally while keeping the persist/path config and the exact prior on-disk locations, so it banks the #3099 win (hand-rolled node:fs/pathlib deleted) without the breaking change or the two silent-data-loss vectors I'd flagged. Verified across both SDKs:

  • Backwards compatible ✅ — persist=True default → LocalFileStorage(~/.strands/memory) + key <name>.json (same path as before); persist=FalseInMemoryStorage; explicit pathLocalFileStorage(dirname) + key basename. Public config surface is unchanged.
  • Atomicity preserved ✅ — I checked LocalFileStorage on both SDKs: writes go to a temp sibling then rename/os.replace, so the "crash mid-write never leaves a partial file" guarantee the old _flush provided still holds. The new _write delegating atomicity to the backend is safe.
  • Browser-bundle safety intact ✅ — the TS _resolve() keeps the dynamic node:os/node:path imports on the default/explicit-path branches, and persist=false uses InMemoryStorage with no node imports.
  • Earlier findings all resolved ✅ — the private-symbol namespacing reach-in (_NAMESPACED/_NamespacedStorage) is gone entirely (rooting LocalFileStorage at the target dir sidesteps it), and the metadata-shape validation in _read is retained.

One actionable item (Important) — PR metadata is now stale

The code is no longer a breaking change, but the PR still advertises one:

  • Title: refactor(memory)!: … — the ! marks a breaking change for semver/changelog automation.
  • Type of Change: Breaking change.
  • The Public API Changes section still documents the abandoned storage: Storage constructor arg and the "ephemeral by default" flip.

Please update the title (drop the !), the type-of-change, and the description to reflect the actual change: an internal refactor onto Storage, no public API or behavior change. Otherwise this will get mislabeled as breaking in release notes and confuse reviewers/consumers.

Minor / optional

  • Explicit-path case uses basename(path) as the storage key un-sanitized (matching the old direct-write behavior). Since keys pass through normalizeKey/_normalize_key, a single-segment basename should round-trip identically — worth a quick confirmation there's a test for a path= with an unusual filename, but low risk.

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.

@opieter-aws opieter-aws removed the api/needs-review Makes changes to the public API surface label Jul 20, 2026
# never does I/O.
if not persist:
self._storage: Storage = InMemoryStorage()
self._key = f"{_sanitize_name(self.name)}.json"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why does the key seem like a file name?

@opieter-aws opieter-aws Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In the LocalFileStorage the key maps directly to a filename. The .json suffix keeps the on disk file byte-identical to the old implementation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

But this is for the InMemoryStorage - does that still write to the file system? (I would have expected not)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah missed that, for InMemoryStore indeed this does not write to the file system, it's just an in memory dict. The key just pairs with LocalFileStorage, but it's easy to drop it here if you think it's confusing

# loop don't each read the same snapshot and clobber one another. Reading inside the critical
# section guarantees add #N sees add #N-1's write. Serialization is per event loop; adds
# driven from separate loops/processes against a shared file remain last-write-wins.
async with self._get_lock():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we up level something like this to the Agent or is this really only protecting memory from multiple agents?

From my reading it's the latter but confirming

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's the latter indeed, the lock only serializes concurrent writes against a single store instance

records = self._load()
# The lock serializes the whole read-modify-write cycle so concurrent adds on the same event
# loop don't each read the same snapshot and clobber one another. Reading inside the critical
# section guarantees add #N sees add #N-1's write. Serialization is per event loop; adds

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Serialization is per event loop

What happens if two agents on two different loops attempt to read/write at the same time? Any protection for multiple event loops?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There's no protection across loops, two agents each using the same test store on their own event loop don’t share a lock. The last write wins.

This is intentional for the test store. With the async storage backend we can't reuse the old locking mechanism and cross-loop coordination would need backend support that the storage interface currently doesn't implement.

I will call this out in the docs to make it explicit.

LMK if you think we need to reconsider this setup.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This would be a bug right? I'm fine deferring it as a fix, but we're effectively saying that Memory is not intended to be shared by concurrent agents?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's a bit more subtle, we're saying that the TestMemoryStore is not intended to be used concurrently across loops. I think it's fine to defer the multi-loop write story to production backends for now, while we add concurrent write mechanism to the Storage object (this is a larger undertaking). But I agree we need to solve this, not just for this use case. I will create an issue with follow up if you agree.

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

Labels

chore Maintenance tasks, dependency updates, CI changes, refactoring with no user-facing impact size/m

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Adopt unified storage interface in TestMemoryStore

6 participants