Skip to content

feat(storage): port unified storage interface to Python - #3227

Closed
strandly-the-agent wants to merge 5 commits into
strands-agents:mainfrom
strandly-the-agent:port/unified-storage-to-python
Closed

strandly-the-agent wants to merge 5 commits into
strands-agents:mainfrom
strandly-the-agent:port/unified-storage-to-python

Conversation

@strandly-the-agent

@strandly-the-agent strandly-the-agent commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Description

Ports the unified storage interface from strands-ts (#3099) into the Python SDK.

Adds a new strands.storage package — a minimal, four-operation persistence primitive over opaque bytes, keyed by /-separated path-like strings — plus three shipped backends and a namespace() factory. This is the Python twin of the strands-ts primitive; it establishes the same foundation for the phased "single persistence primitive for the SDK" design.

from strands.storage import InMemoryStorage, LocalFileStorage, S3Storage

storage = LocalFileStorage("./.strands/")
await storage.write("sessions/abc/snapshot.json", data)   # -> None
raw = await storage.read("sessions/abc/snapshot.json")     # -> bytes | None
keys = await storage.list("sessions/")                      # -> sorted list[str]
await storage.delete("sessions/abc/snapshot.json")

scoped = storage.namespace("offloader")   # a prefixed view; composable
Class Backend
InMemoryStorage dict (testing / serverless)
LocalFileStorage filesystem, atomic host writes (tmp + os.replace), sandbox-aware via for_sandbox()
S3Storage Amazon S3 via boto3 (lazy client, paginated list, region/boto_session mutual-exclusion)

Also adds StorageError (strands.types.exceptions). Key normalization collapses / runs, strips leading/trailing /, and rejects empty keys and any .. segment (path-traversal guard). list is a lexicographically-sorted prefix match.

Related Issues

Port of strands-ts #3099. Requested by lizradway on that PR.

Scope — primitive + context-offloader integration

This PR ships the unified primitive and wires the context-offloader onto it (per maintainer request on this PR), plus a sandbox parity fix. The one TS consumer still not ported is the session SnapshotStorageAdapter, which has no faithful Python analog: strands-ts sessions are snapshot-based (SnapshotStorage/SnapshotLocation/manifest) while strands-py sessions are repository-based (SessionManager ABC + repository_session_manager + file/s3 managers) — there is no SnapshotStorage interface to adapt onto. That one is genuinely N/A rather than deferred.

Context-offloader bridged onto the unified interface

ContextOffloader now accepts either a unified strands.storage.Storage (write/read/delete/list) or the legacy offloader Storage (store/retrieve, now deprecated), mirroring strands-ts #3099:

  • Detection: _is_offloader_storage routes on hasattr(store) and hasattr(retrieve) (TS 'store' in storage && 'retrieve' in storage).
  • Content-type framing: for unified backends each block occupies one key, with the content-type framed into the bytes — [2-byte BE content-type length][utf-8 content-type][content] (_frame_content/_unframe_content); _store_content/_retrieve_content adapt between backends.
  • Plugin-driven eviction: unified Storage has no built-in eviction, so the plugin tracks the store cycle per key and deletes entries older than a new keyword-only evict_after_cycles (default 20, None disables) on BeforeModelCallEvent; the window is forwarded to a legacy InMemoryStorage left at its default. _on_before_model_call is now async (the event loop dispatches this event via invoke_callbacks_async, which awaits coroutine hooks).
  • Deprecation: the legacy offloader Storage/InMemoryStorage/FileStorage/S3Storage get doc-only .. deprecated:: notes (mirrors TS's doc-only @deprecated).

LocalFileStorage sandbox not-found parity

Sandbox-mode read/delete/_list_keys_sandbox now treat NotADirectoryError as not-found alongside FileNotFoundError, matching the host paths and TS isNotFoundError (ENOENT+ENOTDIR). write is intentionally unchanged (TS doesn't treat a non-directory parent as not-found on write).

Type of Change

New feature

Behavior traceability — every strands-ts storage test has a Python counterpart

All 53 source it(...) behaviors across the four TS test files map to a Python test (0 missing); the Python suite adds a few extra edge tests. Independently verified by a fresh-context validation pass.

TS test file behaviors Python test file
in-memory-storage.test.ts 16 test_in_memory_storage.py
namespaced-storage.test.ts 10 test_namespaced_storage.py (+ .namespace() method compose)
local-file-storage.test.node.ts 14 test_local_file_storage.py (+ forced rename-failure cleanup)
s3-storage.test.ts 13 test_s3_storage.py (+ truncated-without-token termination)

58 Python tests, all passing.

Decision log — deviations from the source and why
  • Storage is a non-generic Protocol. TS uses Storage<ListQuery = string>. PEP 696 TypeVar defaults are Python 3.13+ and the SDK targets 3.10+, so the generic-with-default isn't expressible. Dropped it: list(prefix: str). A backend can still widen the parameter (contravariance keeps it structurally compatible), so no capability is lost.
  • NAMESPACED symbol → marker attribute. TS uses Symbol.for('strands.storage.namespaced') + NAMESPACED in obj. Python has no symbols; the equivalent is a module-level marker attribute-name string, set via setattr and detected via getattr. Kept internal (not exported).
  • Copy-on-write only. TS copies bytes on both read and write (Uint8Array.slice()). Python bytes is immutable, so InMemoryStorage copies via bytes(data) on write only; reads hand out the immutable object. The TS "copies on read" test becomes an immutability assertion.
  • S3Storage config = keyword args, boto conventions. TS S3StorageConfig (region XOR s3Client) → explicit kwargs prefix / region / boto_session / boto_client_config, matching the existing S3SessionManager (mutual exclusion is region XOR boto_session; user_agent_extra="strands-agents"). boto3 is a hard dep so there's no lazy-import gap, but the client is still created lazily so constructing an S3Storage never needs AWS.
  • Top-level export. TS surfaces the Storage type from its root barrel. Python idiom is submodule access (from strands.storage import ...), consistent with strands.session/strands.memory, so strands/__init__.py is left untouched. Easy to add a re-export if preferred.
  • Shared latent edges kept faithful to TS (not "fixed"): the scratch-file exclusion uses the same .__strands_tmp substring check as TS (local-file-storage.ts:191/218), and directory listing treats an unknown is_dir (None/undefined) the same way TS does. Diverging would break cross-SDK parity; flagged here for a future cross-SDK fix instead.
One genuine divergence found in review — fixed

An independent review pass caught that S3Storage.list could infinite-loop if a backend returned a page with IsTruncated=true but no NextContinuationToken. strands-ts terminates in that case (continuationToken = IsTruncated ? NextContinuationToken : undefined; while (continuationToken)). Fixed to match, with a regression test. (Unreachable with a spec-compliant S3, but now equivalent to the source.)

Sensitive-surface scan
  • Path traversal: .. segments rejected in both key and prefix normalization (matches TS); normalized keys can't escape base_dir.
  • Filesystem: atomic tmp + os.replace; scratch file removed on failure.
  • AWS: boto3 client lazy; no credentials logged; error messages contain only bucket + normalized key.
  • No subprocess, no deserialization (storage round-trips opaque bytes).

Testing

Ran locally (no functional warnings):

  • pytest tests/strands/storage/ tests/strands/vended_plugins/context_offloader/235 passed (+18 new for the offloader bridge and sandbox parity)

  • ruff check + ruff format --check on touched source & tests — clean

  • mypy ./src — clean in storage/ and context_offloader/ (boto3-stubs[s3])

  • Full tests/strands/vended_plugins/487 passed (no regressions)

  • Two independent fresh-context review passes: correctness → ship; API bar-raiser → land-these-then-approve (all items addressed: privatized the internal _StorageBackend alias, documented evict_after_cycles + unified-storage acceptance, bool rejected in validation).

  • Ran the equivalent of hatch run prepare (ruff check, ruff format --check, mypy, pytest) directly — hatch not available in this environment.

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

🤖 Generated by Strandly (an experimental AI agent). A human should review before merge. Docstrings and a site/ docs page are intentionally deferred — happy to add them or split into a docs PR.

Strandly added 2 commits July 14, 2026 00:30
Ports the unified Storage interface from strands-ts (PR strands-agents#3099): a minimal
four-operation (write/read/delete/list) byte-storage contract plus key
normalization, a namespace() factory, and three backends (InMemoryStorage,
LocalFileStorage, S3Storage). Adds StorageError.

Scope: the storage primitive only. The TS consumer integrations (session
SnapshotStorageAdapter, offloader unified-storage bridge) target subsystems
whose Python architecture diverges and are out of scope for this PR.
…coverage

- S3Storage.list now stops when a page is IsTruncated but omits
  NextContinuationToken, matching strands-ts (was an infinite loop).
- Add tests exercising the S3 termination edge and the LocalFileStorage
  scratch-file cleanup branch on rename failure.
@strandly-the-agent
strandly-the-agent requested a review from a team as a code owner July 14, 2026 00:42
@github-actions github-actions Bot added size/xl python Pull requests that update python code area-persistence Session management or checkpointing enhancement New feature or request labels Jul 14, 2026
- Add **kwargs to the Storage protocol + all backend methods for
  forward-compat (strands-py STYLE_GUIDE / Sandbox convention).
- Rename S3Storage(region=) -> region_name to match S3SessionManager
  and boto3 (idiomatic Python spelling of the concept).
- Export namespace() and StorageError from strands.storage so an
  abstract Storage-typed value can be scoped and its error caught
  without reaching into internal modules.
- NAMESPACED -> _NAMESPACED (internal marker, signal non-public).
- Tests for the public surface (exports + forward-compat kwargs).
@strandly-the-agent

Copy link
Copy Markdown
Contributor Author

Update: ran an independent API bar-raiser pass over the new public surface (it was missing from my earlier validation) and pushed the fixes — the two must-fix-now-or-never public-shape items are addressed. 58 storage tests pass; ruff/format/mypy clean; 279 existing types+offloader tests green.

What the api-bar-raiser flagged and how I addressed it
# Finding Action
🔴 1 Storage protocol methods had no **kwargs — violates strands-py's own STYLE_GUIDE.md/AGENTS.md forward-compat rule (the Sandbox ABC does this) and is unfixable post-release Fixed**kwargs: Any on the protocol + all backend methods
🔴 2 S3Storage(region=…) diverged from the established boto3 spelling (S3SessionManager and boto3 use region_name) Fixed — renamed regionregion_name
🟡 3 namespace() factory was internal but the docstring named it as the scoping path for custom backends; a Storage-typed value couldn't be scoped Fixednamespace() now exported from strands.storage
🟡 4 StorageError didn't travel with the API that raises it (only in strands.types.exceptions; cf. the sandbox errors re-export) Fixed — re-exported from strands.storage
⚪ 6 NAMESPACED spelled like a public constant but internal Fixed_NAMESPACED
✅ 7 Affirmed as correct: dropping the TS <ListQuery=string> generic (PEP 696 defaults are 3.13+; SDK is 3.10+), Protocol over ABC, list method name, keyword-only sandbox no change

Verdict moved from API-changes-requested → the raiser's "land 1–4 and this is an easy API-approve."

Two things I'd like a maintainer's call on (I deliberately didn't force these):

  1. needs-api-review label — this is a net-new public primitive; I can't apply labels from a fork PR. Worth the gate.
  2. Name collision with vended_plugins.context_offloaderInMemoryStorage/S3Storage now exist in both modules with different interfaces (write/read/delete/list vs store/retrieve), and LocalFileStorage vs the offloader's FileStorage. They're in separate modules so it's not a hard conflict, but it's a real DevX/discoverability wrinkle. Since the offloader isn't migrated onto this primitive yet, the unification path (does the offloader eventually adopt strands.storage and drop its bespoke trio?) is a design decision I'd rather not make unilaterally. Happy to do the migration as a follow-up once that's settled.

@opieter-aws

Copy link
Copy Markdown
Contributor

@strandly-the-agent is there any drift between the TS implementation / api and this python implementation?

@strandly-the-agent

Copy link
Copy Markdown
Contributor Author

Short answer: yes, there's drift — but it's almost all deliberate Python-idiom adaptation, plus one scope difference. The contract is faithful: the four-op write/read/delete/list interface over opaque bytes, key/prefix normalization (collapse / runs, strip, reject ..), namespace() composition, lexicographically-sorted prefix list, atomic local writes, and S3 pagination (incl. the truncated-without-token termination) all match strands-ts/src/storage. I read both trees line by line.

The divergences worth your eye are: (1) scope — this ports only the primitive, not the two TS consumers; (2) namespace()/StorageError are public in Python but internal in TS; (3) the S3 injection model differs (boto Session vs an S3Client); (4) boto3 is an eager hard dep vs TS's lazy optional peer-dep. Details below.

1. Scope — the biggest difference (intentional)

TS #3099 also wired the primitive into two consumers; this PR ships only the primitive. Per the PR body, the session SnapshotStorageAdapter has no analog (strands-py sessions are repository-based, not snapshot-based) and the context-offloader already has its own Storage protocol, so bridging it is a behavioral change deferred to a follow-up. Reasonable — but it does mean parity is at the primitive layer only, not the integration layer.

2. Public API surface — Python is wider than TS
  • namespace() is @internal in TS and not exported from its barrel (strands-ts/src/storage/index.ts:16-20). Python exports it from strands.storage (__init__.py:15,26).
  • StorageError lives in TS's root errors module; Python puts it in strands.types.exceptions and re-exports it from strands.storage.
  • S3StorageConfig is an exported type in TS (index.ts:20); Python has no equivalent — S3 options are keyword args.

These were deliberate calls from my own API-bar-raiser pass (see my earlier comment, items #3/#4). Flagging because they widen the Python public surface relative to TS — worth a conscious "yes, keep them public."

3. S3 configuration & dependency posture (genuine drift)
strands-ts strands-py
Inject client s3Client: S3Client boto_session: boto3.Session (a session, not a client)
Region arg region region_name (boto convention)
Mutual exclusion s3Client XOR region boto_session XOR region_name
Extra knobs boto_client_config, user_agent_extra="strands-agents"
AWS dep lazy import(), optional peer-dep import boto3 at module top (s3_storage.py:7), hard dep

The injection model genuinely differs: TS lets you hand in a fully-built client; Python takes a Session instead. And because strands/storage/__init__.py imports s3_storage, merely doing from strands.storage import InMemoryStorage eagerly pulls in boto3 — whereas TS never loads the AWS SDK unless you construct an S3Storage. This matches strands-py's existing S3SessionManager conventions (boto3 is already a core dep), so it's defensible, but it is a real posture difference from TS.

4. Expected idiom adaptations (not concerns)

async def, snake_case, Protocol+@runtime_checkable vs interface, bytes vs Uint8Array, copy-on-write only (Python bytes is immutable), the NAMESPACED symbol → private _strands_storage_namespaced marker attr, keyword-only sandbox, and dropping the Storage<ListQuery=string> generic (PEP 696 TypeVar defaults are 3.13+; the SDK targets 3.10+ — a backend can still widen the param structurally). All faithful in behavior.

5. Two minor behavioral edges I'd note
  • S3 not-found codes: Python treats {NoSuchKey, NotFound, 404} as “missing → return None” (s3_storage.py:18); TS only checks NoSuchKey/NotFound. The extra 404 is a defensible superset (boto surfaces it on some paths) but it's a small behavioral widening vs TS.
  • LocalFileStorage sandbox mode: host-mode read/delete catch both FileNotFoundError and NotADirectoryError (matching TS's isNotFoundError, which lumps ENOENT+ENOTDIR), but the sandbox paths catch only FileNotFoundError — so a NotADirectoryError from a sandbox raises StorageError where TS would return null. Host-vs-sandbox asymmetry, low impact, but not 1:1 with TS. Easy tightening if you want exact parity.

Net: the primitive is a faithful port; the drift is intentional idiom/scope choices plus the S3 injection & dependency-posture differences above. The public-surface items (#2) and the two edges (#5) are the only things I'd ask you to explicitly bless or wave off.

🤖 Generated by Strandly (an experimental AI agent) — please sanity-check the specifics before relying on them.

@opieter-aws

Copy link
Copy Markdown
Contributor

@strandly-the-agent

LocalFileStorage sandbox mode: host-mode read/delete catch both FileNotFoundError and NotADirectoryError (matching TS's isNotFoundError, which lumps ENOENT+ENOTDIR), but the sandbox paths catch only FileNotFoundError — so a NotADirectoryError from a sandbox raises StorageError where TS would return null. Host-vs-sandbox asymmetry, low impact, but not 1:1 with TS. Easy tightening if you want exact parity.

Fix this

the context-offloader already has its own Storage protocol, so bridging it is a behavioral change deferred to a follow-up.

Do full parity with the TS PR, implement for offloader too

strandly added 2 commits July 14, 2026 13:18
…x not-found parity

Wire ContextOffloader onto the unified strands.storage interface (parity with
strands-ts strands-agents#3099) and tighten LocalFileStorage sandbox error handling.

- ContextOffloader now accepts a unified Storage (write/read/delete/list) as
  well as a legacy offloader Storage (store/retrieve, now deprecated).
  Content-type is framed into a single unified-storage key
  ([2-byte BE len][content-type][content]); _store_content/_retrieve_content
  adapt between the two backends.
- Plugin-driven eviction for unified backends (they have no built-in eviction):
  evict_after_cycles (default 20, None disables) + per-key stored-cycle tracking.
  The window is forwarded to a legacy InMemoryStorage left at its default.
- LocalFileStorage sandbox read/delete/list now treat NotADirectoryError as
  not-found (matches host paths and strands-ts isNotFoundError = ENOENT+ENOTDIR).
- Legacy offloader storage classes marked deprecated (doc-only, mirroring TS).

Tests: +29 (framing round-trip, backend detection, adapters, e2e offload/retrieve
through unified storage, plugin eviction + disable, validation, window forwarding,
sandbox NotADirectoryError parity). pytest/ruff/mypy green.
…cter validation

Independent API bar-raiser + correctness passes:
- Privatize StorageBackend -> _StorageBackend (internal; TS inlines the union).
- Class docstring: document evict_after_cycles and that a unified strands.storage
  Storage is accepted/preferred; class example uses the unified import.
- evict_after_cycles rejects bool (bool is an int subclass; True must not pass as 1).
- Note unified eviction is measured from store-time, not last-access.
- Add bool-rejection test.
@strandly-the-agent

Copy link
Copy Markdown
Contributor Author

Both done and pushed (2 commits on this branch). pytest / ruff / mypy green, and I ran two independent fresh-context review passes over the result (correctness → ship; API bar-raiser → land-these-then-approve, all items addressed).

  1. Sandbox NotADirectoryError parity — fixed in LocalFileStorage. All three sandbox catch sites (read / delete / _list_keys_sandbox) now treat NotADirectoryError as not-found alongside FileNotFoundError, matching the host paths and TS isNotFoundError (ENOENT+ENOTDIR). write is deliberately left alone — TS doesn't treat a non-directory parent as not-found on write either.
  2. Offloader bridged onto the unified interfaceContextOffloader now accepts a unified strands.storage.Storage or the legacy offloader Storage (now deprecated), mirroring strands-ts feat: add unified storage interface #3099.

One honest scope note before the details: the TS PR also wired the session SnapshotStorageAdapter. That one still has no faithful Python analog (strands-py sessions are repository-based, not snapshot-based — there's no SnapshotStorage to adapt onto), so it's genuinely N/A rather than deferred. You asked specifically for the offloader, which is done.

How the offloader bridge works (parity with strands-ts)
  • Dual-accept + detection: _is_offloader_storage routes on hasattr(store) and hasattr(retrieve) (TS 'store' in storage && 'retrieve' in storage). Unified backends expose only write/read/delete/list, so there's no misroute.
  • Content-type framing: unified backends store one key per block with the content-type framed into the bytes — [2-byte big-endian content-type length][utf-8 content-type][content] (_frame_content/_unframe_content), so metadata doesn't consume a second key. _store_content/_retrieve_content adapt between the two backends.
  • Plugin-driven eviction: unified Storage has no built-in eviction, so the plugin tracks the store cycle per key (_key_stored_at) and deletes entries older than evict_after_cycles (new keyword-only param, default 20, None disables) on BeforeModelCallEvent. The window is forwarded to a legacy InMemoryStorage left at its default, so one knob governs both. _on_before_model_call is now async — verified the event loop dispatches this event via invoke_callbacks_async, which awaits coroutine hooks.
  • Deprecation: legacy Storage/InMemoryStorage/FileStorage/S3Storage in the offloader module got doc-only .. deprecated:: notes (mirrors TS's doc-only @deprecated; no runtime warning).
Tests & verification
  • +18 tests (test_unified_storage_bridge.py, 17 cases + sandbox parity cases in test_local_file_storage.py): framing round-trip (incl. empty content / empty content-type / 256-byte binary), backend detection both ways, store/retrieve adapters, end-to-end offload→retrieve through a unified InMemoryStorage, eviction boundary (stored_cycle < threshold) + disable, evict_after_cycles validation (incl. bool rejection), legacy-window forwarding (both branches), and NotADirectoryError parity across read/delete/list (plus “other errors still wrap to StorageError”).
  • 235 storage+offloader tests pass; ruff check + ruff format --check clean on touched files; mypy ./src clean in storage/ and context_offloader/.
  • Diff: +479/-29 across 6 files.

Faithful-to-TS edges I did not “fix” (flagging rather than diverging): _key_stored_at isn't pruned when eviction is disabled, and unified eviction is measured from store-time (not last-access, unlike the legacy in-memory backend). Both mirror strands-ts exactly — happy to tighten either as a cross-SDK follow-up if you'd prefer.

🤖 Generated by Strandly (an experimental AI agent) — please review before merge.

@lizradway

Copy link
Copy Markdown
Member

Closing out in favor of #3259

@lizradway lizradway closed this Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-persistence Session management or checkpointing enhancement New feature or request python Pull requests that update python code size/xl

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants