From 0dd1a7581949234c8d761886fff9d164ed32ffae Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:22:23 -0400 Subject: [PATCH 01/15] feat(storage): add unified Storage interface and wire into all subsystems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the unified Storage interface — the single persistence primitive for the SDK — and wires it into the session manager and context offloader. - Storage interface: put/get/delete/list on Uint8Array blobs with path-like keys - InMemoryStorage (LRU eviction via maxEntries), LocalFileStorage (atomic writes, sandbox-aware), S3Storage - initStorage plugin lifecycle hook distributes agent-level storage to plugins - SnapshotStorageAdapter bridges unified Storage → SnapshotStorage - SessionManager and ContextOffloader accept unified Storage, default via initStorage - Legacy storage classes marked @deprecated with backwards-compat preserved - Framed binary format: content-type is encoded into stored bytes so each offloaded block occupies exactly one storage key (no sidecar metadata keys) - Unified Storage uses LRU (maxEntries) for eviction; legacy backends retain their own internal turn-based eviction - SessionManager._snapshotStorage throws SessionError instead of ! assertion - LocalFileStorage uses unique temp filenames to prevent concurrent write corruption --- strands-ts/package.json | 4 + .../__tests__/agent.context-manager.test.ts | 5 +- strands-ts/src/agent/agent.ts | 19 +- strands-ts/src/errors.ts | 19 ++ strands-ts/src/index.ts | 4 + strands-ts/src/plugins/plugin.ts | 15 + strands-ts/src/plugins/registry.ts | 16 + .../snapshot-storage-adapter.test.ts | 296 ++++++++++++++++++ strands-ts/src/session/file-storage.ts | 3 + strands-ts/src/session/s3-storage.ts | 3 + strands-ts/src/session/session-manager.ts | 67 +++- .../src/session/snapshot-storage-adapter.ts | 187 +++++++++++ strands-ts/src/session/storage.ts | 12 +- .../__tests__/in-memory-storage.test.ts | 190 +++++++++++ .../__tests__/local-file-storage.test.node.ts | 117 +++++++ .../src/storage/__tests__/s3-storage.test.ts | 159 ++++++++++ strands-ts/src/storage/in-memory-storage.ts | 119 +++++++ strands-ts/src/storage/index.ts | 20 ++ strands-ts/src/storage/local-file-storage.ts | 225 +++++++++++++ strands-ts/src/storage/normalize.ts | 36 +++ strands-ts/src/storage/s3-storage.ts | 160 ++++++++++ strands-ts/src/storage/storage.ts | 49 +++ .../__tests__/plugin.test.ts | 34 ++ .../context-offloader/plugin.ts | 133 ++++++-- .../context-offloader/storage.ts | 25 +- 25 files changed, 1866 insertions(+), 51 deletions(-) create mode 100644 strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts create mode 100644 strands-ts/src/session/snapshot-storage-adapter.ts create mode 100644 strands-ts/src/storage/__tests__/in-memory-storage.test.ts create mode 100644 strands-ts/src/storage/__tests__/local-file-storage.test.node.ts create mode 100644 strands-ts/src/storage/__tests__/s3-storage.test.ts create mode 100644 strands-ts/src/storage/in-memory-storage.ts create mode 100644 strands-ts/src/storage/index.ts create mode 100644 strands-ts/src/storage/local-file-storage.ts create mode 100644 strands-ts/src/storage/normalize.ts create mode 100644 strands-ts/src/storage/s3-storage.ts create mode 100644 strands-ts/src/storage/storage.ts diff --git a/strands-ts/package.json b/strands-ts/package.json index 0d335d4927..c11d6e48e2 100644 --- a/strands-ts/package.json +++ b/strands-ts/package.json @@ -121,6 +121,10 @@ "types": "./dist/src/vended-plugins/index.d.ts", "default": "./dist/src/vended-plugins/index.js" }, + "./storage": { + "types": "./dist/src/storage/index.d.ts", + "default": "./dist/src/storage/index.js" + }, "./sandbox": { "types": "./dist/src/sandbox/index.d.ts", "default": "./dist/src/sandbox/index.js" diff --git a/strands-ts/src/agent/__tests__/agent.context-manager.test.ts b/strands-ts/src/agent/__tests__/agent.context-manager.test.ts index a5296a53ae..511a5c88b4 100644 --- a/strands-ts/src/agent/__tests__/agent.context-manager.test.ts +++ b/strands-ts/src/agent/__tests__/agent.context-manager.test.ts @@ -4,7 +4,8 @@ import { MockMessageModel } from '../../__fixtures__/mock-message-model.js' import { SlidingWindowConversationManager } from '../../conversation-manager/sliding-window-conversation-manager.js' import { SummarizingConversationManager } from '../../conversation-manager/summarizing-conversation-manager.js' import { ContextOffloader } from '../../vended-plugins/context-offloader/plugin.js' -import { InMemoryStorage } from '../../vended-plugins/context-offloader/storage.js' +import { InMemoryStorage as LegacyInMemoryStorage } from '../../vended-plugins/context-offloader/storage.js' +import { InMemoryStorage } from '../../storage/in-memory-storage.js' import type { ConversationManager } from '../../conversation-manager/conversation-manager.js' function internals(agent: Agent): any { @@ -87,7 +88,7 @@ describe('Agent contextManager', () => { it('does not add duplicate ContextOffloader if user provides one', () => { const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) const userOffloader = new ContextOffloader({ - storage: new InMemoryStorage(), + storage: new LegacyInMemoryStorage(), maxResultTokens: 3000, previewTokens: 1000, }) diff --git a/strands-ts/src/agent/agent.ts b/strands-ts/src/agent/agent.ts index 42ec30db83..9b39d142e6 100644 --- a/strands-ts/src/agent/agent.ts +++ b/strands-ts/src/agent/agent.ts @@ -48,7 +48,7 @@ import { SummarizingConversationManager } from '../conversation-manager/summariz import { NullConversationManager } from '../conversation-manager/null-conversation-manager.js' import { ConversationManager } from '../conversation-manager/conversation-manager.js' import { ContextOffloader } from '../vended-plugins/context-offloader/plugin.js' -import { InMemoryStorage } from '../vended-plugins/context-offloader/storage.js' +import { InMemoryStorage } from '../storage/in-memory-storage.js' import { HookRegistryImplementation } from '../hooks/registry.js' import { MiddlewareRegistry, InvokeModelStage, ExecuteToolStage, AgentStreamStage } from '../middleware/index.js' import type { @@ -119,6 +119,7 @@ import type { TakeSnapshotOptions } from './snapshot.js' import type { Snapshot } from '../types/snapshot.js' import type { Sandbox } from '../sandbox/base.js' import { defaultSandbox } from '../sandbox/default.js' +import type { Storage } from '../storage/storage.js' import { summarizeContextTool, truncateContextTool, @@ -295,6 +296,12 @@ export type AgentConfig = { * Defaults to `'concurrent'`. See {@link ToolExecutorStrategy} for details. */ toolExecutor?: ToolExecutorStrategy + /** + * Unified storage backend shared with all plugins that implement `initStorage`. + * Plugins receive this instance during initialization and use it for persistence + * rather than requiring the user to pass storage to each plugin separately. + */ + storage?: Storage /** * Execution environment for running commands, code, and file operations. * When provided, sandbox-aware tools route operations through it. @@ -569,7 +576,7 @@ export class Agent implements LocalAgent, InvokableAgent { ...((config?.contextManager === 'auto' || config?.contextManager === 'agentic') && !hasOffloader ? [ new ContextOffloader({ - storage: new InMemoryStorage(), + storage: new InMemoryStorage({ maxEntries: 200 }), maxResultTokens: config?.contextManager === 'agentic' ? AGENTIC_CONTEXT_MANAGER_MAX_RESULT_TOKENS @@ -583,6 +590,10 @@ export class Agent implements LocalAgent, InvokableAgent { new ModelPlugin(this.model), ]) + if (config?.storage) { + this._pluginRegistry.setStorage(config.storage) + } + if (config?.systemPrompt !== undefined) { this.systemPrompt = systemPromptFromData(config.systemPrompt) } @@ -716,7 +727,9 @@ export class Agent implements LocalAgent, InvokableAgent { | MiddlewareWrapPhase | MiddlewareOutputPhase, handler: - MiddlewareHandler | MiddlewareInputHandler | MiddlewareOutputHandler + | MiddlewareHandler + | MiddlewareInputHandler + | MiddlewareOutputHandler ): () => void { if ('_phase' in stageOrPhase) { const phase = stageOrPhase as { _phase: MiddlewarePhaseKind; _stage: MiddlewareStage } diff --git a/strands-ts/src/errors.ts b/strands-ts/src/errors.ts index 7aac3c8505..6a64b32391 100644 --- a/strands-ts/src/errors.ts +++ b/strands-ts/src/errors.ts @@ -254,3 +254,22 @@ export class DefaultNotConfiguredError extends Error { this.name = 'DefaultNotConfiguredError' } } + +/** + * Error thrown when a storage operation fails. + * + * Wraps backend-specific errors (filesystem, S3, network) with a uniform type + * that consumers can catch without knowing which backend is in use. + */ +export class StorageError extends Error { + /** + * Creates a new StorageError. + * + * @param message - Error message describing the storage failure + * @param options - Optional error options including cause for error chaining + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'StorageError' + } +} diff --git a/strands-ts/src/index.ts b/strands-ts/src/index.ts index a3e2314a8b..f649ba1614 100644 --- a/strands-ts/src/index.ts +++ b/strands-ts/src/index.ts @@ -38,6 +38,7 @@ export { StructuredOutputError, ToolNotFoundError, DefaultNotConfiguredError, + StorageError, } from './errors.js' // Interrupt system @@ -304,6 +305,9 @@ export { AgentTrace } from './telemetry/tracer.js' // Local Metrics export { AgentMetrics } from './telemetry/meter.js' +// Storage +export type { Storage } from './storage/storage.js' + // Sandbox export { Sandbox, type ExecuteOptions } from './sandbox/base.js' export { PosixShellSandbox } from './sandbox/posix-shell.js' diff --git a/strands-ts/src/plugins/plugin.ts b/strands-ts/src/plugins/plugin.ts index b270875ecb..c59ddcbe88 100644 --- a/strands-ts/src/plugins/plugin.ts +++ b/strands-ts/src/plugins/plugin.ts @@ -5,6 +5,7 @@ * add behavior changes to agents through hook registration and custom initialization. */ +import type { Storage } from '../storage/storage.js' import type { Tool } from '../tools/tool.js' import type { LocalAgent } from '../types/agent.js' @@ -67,6 +68,20 @@ export interface Plugin { */ initAgent(agent: LocalAgent): void | Promise + /** + * Receives the agent's configured storage instance. + * + * Called by the plugin registry during initialization when the agent has a + * `storage` configured. Plugins that need persistence should use this storage + * instance rather than requiring the user to pass storage separately. + * + * A plugin that already has storage configured via its constructor should + * ignore this call (constructor override takes priority). + * + * @param storage - The agent-level storage instance + */ + initStorage?(storage: Storage): void | Promise + /** * Returns tools provided by this plugin for auto-registration. * Implement to provide plugin-specific tools. diff --git a/strands-ts/src/plugins/registry.ts b/strands-ts/src/plugins/registry.ts index ea6c3c79cf..95c5ddb480 100644 --- a/strands-ts/src/plugins/registry.ts +++ b/strands-ts/src/plugins/registry.ts @@ -2,6 +2,7 @@ * Plugin registry for managing plugins attached to an agent. */ +import type { Storage } from '../storage/storage.js' import type { Plugin } from './plugin.js' import type { LocalAgent } from '../types/agent.js' @@ -14,12 +15,22 @@ import type { LocalAgent } from '../types/agent.js' export class PluginRegistry { private readonly _plugins: Map private readonly _pending: Plugin[] + private _storage: Storage | undefined constructor(plugins: Plugin[] = []) { this._plugins = new Map() this._pending = [...plugins] } + /** + * Sets the storage instance to distribute to plugins during initialization. + * + * @param storage - The agent-level storage instance + */ + setStorage(storage: Storage): void { + this._storage = storage + } + /** * Initialize all pending plugins with the agent. * Safe to call multiple times — only runs once per pending batch. @@ -44,6 +55,11 @@ export class PluginRegistry { agent.toolRegistry.add(tools) } + // initStorage runs first so plugins can fall back to agent-level storage before initAgent wires hooks + if (this._storage && plugin.initStorage) { + await plugin.initStorage(this._storage) + } + await plugin.initAgent(agent) } } diff --git a/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts b/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts new file mode 100644 index 0000000000..377dbf5309 --- /dev/null +++ b/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { SnapshotStorageAdapter } from '../snapshot-storage-adapter.js' +import { InMemoryStorage } from '../../storage/in-memory-storage.js' +import { SessionError } from '../../errors.js' +import { createTestSnapshot, createTestManifest, createTestScope } from '../../__fixtures__/mock-storage-provider.js' +import type { SnapshotLocation } from '../storage.js' + +const SCOPE_ID = 'test-agent' + +function createLocation(overrides: Partial = {}): SnapshotLocation { + return { + sessionId: 'test-session', + scope: createTestScope(), + scopeId: SCOPE_ID, + ...overrides, + } +} + +function uuidV7(index: number): string { + const hex = index.toString(16).padStart(12, '0') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-7000-8000-000000000000` +} + +describe('SnapshotStorageAdapter', () => { + let backend: InMemoryStorage + let adapter: SnapshotStorageAdapter + + beforeEach(() => { + backend = new InMemoryStorage({ maxEntries: null }) + adapter = new SnapshotStorageAdapter(backend) + }) + + describe('saveSnapshot', () => { + it('saves snapshot as latest', async () => { + const location = createLocation() + const snapshot = createTestSnapshot() + + await adapter.saveSnapshot({ location, snapshotId: uuidV7(1), isLatest: true, snapshot }) + + const keys = await backend.list('sessions/') + expect(keys).toContainEqual(expect.stringContaining('snapshot_latest.json')) + }) + + it('saves snapshot to history', async () => { + const location = createLocation() + const snapshot = createTestSnapshot() + const id = uuidV7(1) + + await adapter.saveSnapshot({ location, snapshotId: id, isLatest: false, snapshot }) + + const keys = await backend.list('sessions/') + expect(keys).toContainEqual(expect.stringContaining(`immutable_history/snapshot_${id}.json`)) + }) + + it('round-trips snapshot data through loadSnapshot', async () => { + const location = createLocation() + const snapshot = createTestSnapshot({ appData: { custom: 'value' } }) + const id = uuidV7(1) + + await adapter.saveSnapshot({ location, snapshotId: id, isLatest: false, snapshot }) + const loaded = await adapter.loadSnapshot({ location, snapshotId: id }) + + expect(loaded).toEqual(snapshot) + }) + }) + + describe('loadSnapshot', () => { + it('returns null when snapshot does not exist', async () => { + const location = createLocation() + + const result = await adapter.loadSnapshot({ location, snapshotId: uuidV7(99) }) + + expect(result).toBeNull() + }) + + it('loads latest snapshot when no snapshotId provided', async () => { + const location = createLocation() + const snapshot = createTestSnapshot({ appData: { version: 'latest' } }) + + await adapter.saveSnapshot({ location, snapshotId: uuidV7(1), isLatest: true, snapshot }) + const loaded = await adapter.loadSnapshot({ location }) + + expect(loaded).toEqual(snapshot) + }) + + it('returns null for latest when no latest exists', async () => { + const location = createLocation() + + const result = await adapter.loadSnapshot({ location }) + + expect(result).toBeNull() + }) + }) + + describe('listSnapshotIds', () => { + it('returns empty array when no snapshots exist', async () => { + const location = createLocation() + + const ids = await adapter.listSnapshotIds({ location }) + + expect(ids).toEqual([]) + }) + + it('lists snapshot IDs sorted chronologically', async () => { + const location = createLocation() + const id1 = uuidV7(1) + const id2 = uuidV7(2) + const id3 = uuidV7(3) + + await adapter.saveSnapshot({ location, snapshotId: id2, isLatest: false, snapshot: createTestSnapshot() }) + await adapter.saveSnapshot({ location, snapshotId: id1, isLatest: false, snapshot: createTestSnapshot() }) + await adapter.saveSnapshot({ location, snapshotId: id3, isLatest: false, snapshot: createTestSnapshot() }) + + const ids = await adapter.listSnapshotIds({ location }) + + expect(ids).toEqual([id1, id2, id3]) + }) + + it('does not include latest in listing', async () => { + const location = createLocation() + const id = uuidV7(1) + + await adapter.saveSnapshot({ location, snapshotId: id, isLatest: true, snapshot: createTestSnapshot() }) + await adapter.saveSnapshot({ location, snapshotId: id, isLatest: false, snapshot: createTestSnapshot() }) + + const ids = await adapter.listSnapshotIds({ location }) + + expect(ids).toEqual([id]) + }) + + it('respects limit parameter', async () => { + const location = createLocation() + for (let i = 1; i <= 5; i++) { + await adapter.saveSnapshot({ location, snapshotId: uuidV7(i), isLatest: false, snapshot: createTestSnapshot() }) + } + + const ids = await adapter.listSnapshotIds({ location, limit: 2 }) + + expect(ids).toHaveLength(2) + expect(ids).toEqual([uuidV7(1), uuidV7(2)]) + }) + + it('returns empty array when limit is 0', async () => { + const location = createLocation() + await adapter.saveSnapshot({ location, snapshotId: uuidV7(1), isLatest: false, snapshot: createTestSnapshot() }) + + const ids = await adapter.listSnapshotIds({ location, limit: 0 }) + + expect(ids).toEqual([]) + }) + + it('respects startAfter cursor', async () => { + const location = createLocation() + const id1 = uuidV7(1) + const id2 = uuidV7(2) + const id3 = uuidV7(3) + + await adapter.saveSnapshot({ location, snapshotId: id1, isLatest: false, snapshot: createTestSnapshot() }) + await adapter.saveSnapshot({ location, snapshotId: id2, isLatest: false, snapshot: createTestSnapshot() }) + await adapter.saveSnapshot({ location, snapshotId: id3, isLatest: false, snapshot: createTestSnapshot() }) + + const ids = await adapter.listSnapshotIds({ location, startAfter: id1 }) + + expect(ids).toEqual([id2, id3]) + }) + + it('combines limit and startAfter', async () => { + const location = createLocation() + for (let i = 1; i <= 5; i++) { + await adapter.saveSnapshot({ location, snapshotId: uuidV7(i), isLatest: false, snapshot: createTestSnapshot() }) + } + + const ids = await adapter.listSnapshotIds({ location, startAfter: uuidV7(2), limit: 2 }) + + expect(ids).toEqual([uuidV7(3), uuidV7(4)]) + }) + }) + + describe('deleteSession', () => { + it('deletes all data for the session', async () => { + const location = createLocation() + await adapter.saveSnapshot({ location, snapshotId: uuidV7(1), isLatest: false, snapshot: createTestSnapshot() }) + await adapter.saveSnapshot({ location, snapshotId: uuidV7(1), isLatest: true, snapshot: createTestSnapshot() }) + await adapter.saveManifest({ location, manifest: createTestManifest() }) + + await adapter.deleteSession({ sessionId: 'test-session' }) + + const keys = await backend.list('sessions/test-session/') + expect(keys).toHaveLength(0) + }) + + it('does not affect other sessions', async () => { + const location1 = createLocation({ sessionId: 'session-1' }) + const location2 = createLocation({ sessionId: 'session-2' }) + + await adapter.saveSnapshot({ + location: location1, + snapshotId: uuidV7(1), + isLatest: false, + snapshot: createTestSnapshot(), + }) + await adapter.saveSnapshot({ + location: location2, + snapshotId: uuidV7(2), + isLatest: false, + snapshot: createTestSnapshot(), + }) + + await adapter.deleteSession({ sessionId: 'session-1' }) + + const keys1 = await backend.list('sessions/session-1/') + const keys2 = await backend.list('sessions/session-2/') + expect(keys1).toHaveLength(0) + expect(keys2.length).toBeGreaterThan(0) + }) + + it('throws on invalid session ID', async () => { + await expect(adapter.deleteSession({ sessionId: 'INVALID!' })).rejects.toThrow() + }) + }) + + describe('saveManifest / loadManifest', () => { + it('round-trips manifest data', async () => { + const location = createLocation() + const manifest = createTestManifest({ updatedAt: '2025-06-01T00:00:00.000Z' }) + + await adapter.saveManifest({ location, manifest }) + const loaded = await adapter.loadManifest({ location }) + + expect(loaded).toEqual(manifest) + }) + + it('returns default manifest when none exists', async () => { + const location = createLocation() + + const manifest = await adapter.loadManifest({ location }) + + expect(manifest.schemaVersion).toBe('1.0') + expect(manifest.updatedAt).toBeDefined() + }) + }) + + describe('custom basePrefix', () => { + it('uses custom prefix for all keys', async () => { + const customAdapter = new SnapshotStorageAdapter(backend, 'custom/prefix') + const location = createLocation() + + await customAdapter.saveSnapshot({ + location, + snapshotId: uuidV7(1), + isLatest: true, + snapshot: createTestSnapshot(), + }) + + const defaultKeys = await backend.list('sessions/') + const customKeys = await backend.list('custom/prefix/') + expect(defaultKeys).toHaveLength(0) + expect(customKeys.length).toBeGreaterThan(0) + }) + }) + + describe('error handling', () => { + it('wraps storage write errors in SessionError', async () => { + const failingBackend: InMemoryStorage = new InMemoryStorage({ maxEntries: null }) + failingBackend.put = async () => { + throw new Error('disk full') + } + const failAdapter = new SnapshotStorageAdapter(failingBackend) + const location = createLocation() + + await expect( + failAdapter.saveSnapshot({ location, snapshotId: uuidV7(1), isLatest: false, snapshot: createTestSnapshot() }) + ).rejects.toThrow(SessionError) + }) + + it('wraps storage read errors in SessionError', async () => { + const failingBackend: InMemoryStorage = new InMemoryStorage({ maxEntries: null }) + failingBackend.get = async () => { + throw new Error('network timeout') + } + const failAdapter = new SnapshotStorageAdapter(failingBackend) + const location = createLocation() + + await expect(failAdapter.loadSnapshot({ location, snapshotId: uuidV7(1) })).rejects.toThrow(SessionError) + }) + + it('throws SessionError on corrupted JSON', async () => { + const location = createLocation() + const key = 'sessions/test-session/scopes/agent/test-agent/snapshots/snapshot_latest.json' + await backend.put(key, new TextEncoder().encode('not valid json{{{')) + + await expect(adapter.loadSnapshot({ location })).rejects.toThrow(SessionError) + await expect(adapter.loadSnapshot({ location })).rejects.toThrow(/Corrupted JSON/) + }) + }) +}) diff --git a/strands-ts/src/session/file-storage.ts b/strands-ts/src/session/file-storage.ts index 41b05942ef..edbcc80013 100644 --- a/strands-ts/src/session/file-storage.ts +++ b/strands-ts/src/session/file-storage.ts @@ -14,6 +14,9 @@ const SCHEMA_VERSION = '1.0' * File-based implementation of SnapshotStorage. * Persists session snapshots to the local filesystem under a configurable base directory. * + * @deprecated Pass a unified `Storage` (e.g. `LocalFileStorage` from `@strands-agents/sdk/storage`) + * to `SessionManagerConfig.storage` instead. The session manager wraps it internally. + * * Directory layout: * ``` * //scopes///snapshots/ diff --git a/strands-ts/src/session/s3-storage.ts b/strands-ts/src/session/s3-storage.ts index b60aa4b6b1..02d4d0b6cc 100644 --- a/strands-ts/src/session/s3-storage.ts +++ b/strands-ts/src/session/s3-storage.ts @@ -35,6 +35,9 @@ export type S3StorageConfig = { * S3-based implementation of SnapshotStorage. * Persists session snapshots as JSON objects in an S3 bucket. * + * @deprecated Pass a unified `Storage` (e.g. `S3Storage` from `@strands-agents/sdk/storage`) + * to `SessionManagerConfig.storage` instead. The session manager wraps it internally. + * * Object key layout: * ``` * [/]/scopes///snapshots/ diff --git a/strands-ts/src/session/session-manager.ts b/strands-ts/src/session/session-manager.ts index b36d25d40a..41a483618c 100644 --- a/strands-ts/src/session/session-manager.ts +++ b/strands-ts/src/session/session-manager.ts @@ -1,4 +1,7 @@ import type { SnapshotStorage, SnapshotLocation } from './storage.js' +import type { Storage } from '../storage/storage.js' +import { SnapshotStorageAdapter } from './snapshot-storage-adapter.js' +import { SessionError } from '../errors.js' import { validateIdentifier } from './validation.js' import type { SnapshotTriggerCallback } from './types.js' import type { Plugin } from '../plugins/plugin.js' @@ -53,10 +56,16 @@ export type SaveLatestStrategy = 'message' | 'invocation' | 'trigger' export type MultiAgentSaveLatestStrategy = 'node' | 'invocation' export interface SessionManagerConfig { - /** Pluggable storage backends for snapshot persistence. Defaults to FileStorage in Node.js; required in browser environments. */ - storage: { - snapshot: SnapshotStorage - } + /** + * Storage backend for snapshot persistence. + * + * Accepts either: + * - A unified {@link Storage} instance (recommended) — wrapped internally with {@link SnapshotStorageAdapter} + * - A legacy `{ snapshot: SnapshotStorage }` object for backwards compatibility + * + * When omitted, the session manager receives storage from the agent via `initStorage`. + */ + storage?: Storage | { snapshot: SnapshotStorage } /** Unique session identifier. Defaults to `'default-session'`. */ sessionId?: string /** When to save snapshot_latest. Default: `'invocation'` (after each agent invocation completes). See {@link SaveLatestStrategy} for details. */ @@ -91,11 +100,12 @@ export interface SessionManagerConfig { */ export class SessionManager implements Plugin, MultiAgentPlugin { private readonly _sessionId: string - private readonly _storage: { snapshot: SnapshotStorage } + private _storage: { snapshot: SnapshotStorage } | undefined private readonly _saveLatestOn: SaveLatestStrategy private readonly _snapshotTrigger?: SnapshotTriggerCallback | undefined private readonly _multiAgentSaveLatestOn: MultiAgentSaveLatestStrategy private _multiAgentRestoredIds = new Set() + private readonly _hasExplicitStorage: boolean /** * Unique identifier for this plugin. @@ -104,16 +114,45 @@ export class SessionManager implements Plugin, MultiAgentPlugin { return 'strands:session-manager' } - constructor(config: SessionManagerConfig) { + constructor(config: SessionManagerConfig = {}) { this._sessionId = validateIdentifier(config.sessionId ?? 'default-session') - this._storage = { snapshot: config.storage.snapshot } + this._storage = config.storage ? { snapshot: this._resolveSnapshotStorage(config.storage) } : undefined + this._hasExplicitStorage = config.storage !== undefined this._saveLatestOn = config.saveLatestOn ?? 'invocation' this._multiAgentSaveLatestOn = config.multiAgentSaveLatestOn ?? 'node' this._snapshotTrigger = config.snapshotTrigger } + /** + * Receives the agent-level unified storage when no explicit storage was provided. + * + * @param storage - The agent-level storage instance + */ + initStorage(storage: Storage): void { + if (this._hasExplicitStorage) return + this._storage = { snapshot: new SnapshotStorageAdapter(storage) } + } + + private get _snapshotStorage(): SnapshotStorage { + if (!this._storage) { + throw new SessionError('SessionManager storage not initialized') + } + return this._storage.snapshot + } + + private _resolveSnapshotStorage(storage: Storage | { snapshot: SnapshotStorage }): SnapshotStorage { + if ('snapshot' in storage) return storage.snapshot + return new SnapshotStorageAdapter(storage) + } + /** Initializes the plugin by registering lifecycle hook callbacks. */ public initAgent(agent: LocalAgent): void { + if (!this._storage) { + throw new Error( + 'SessionManager requires a storage backend. ' + + 'Pass storage in the SessionManager config or set storage on the Agent config.' + ) + } agent.addHook(InitializedEvent, async (event) => { await this._onAgentInitialized(event) }) @@ -156,17 +195,17 @@ export class SessionManager implements Plugin, MultiAgentPlugin { const location = isAgent ? this._location(params.target as LocalAgent) : this._multiAgentLocation(params.target as MultiAgent) - await this._storage.snapshot.saveSnapshot({ location, snapshotId, isLatest: params.isLatest, snapshot }) + await this._snapshotStorage.saveSnapshot({ location, snapshotId, isLatest: params.isLatest, snapshot }) } /** Deletes all snapshots and manifests for this session from storage. */ async deleteSession(): Promise { - await this._storage.snapshot.deleteSession({ sessionId: this._sessionId }) + await this._snapshotStorage.deleteSession({ sessionId: this._sessionId }) } /** Lists all available immutable snapshot IDs for the given agent target. */ async listSnapshotIds(params: { target: LocalAgent; limit?: number; startAfter?: string }): Promise { - return this._storage.snapshot.listSnapshotIds({ + return this._snapshotStorage.listSnapshotIds({ location: this._location(params.target), ...(params.limit !== undefined && { limit: params.limit }), ...(params.startAfter !== undefined && { startAfter: params.startAfter }), @@ -189,7 +228,7 @@ export class SessionManager implements Plugin, MultiAgentPlugin { const location = isAgent ? this._location(params.target as LocalAgent) : this._multiAgentLocation(params.target as MultiAgent) - const snapshot = await this._storage.snapshot.loadSnapshot({ + const snapshot = await this._snapshotStorage.loadSnapshot({ location, ...(params.snapshotId !== undefined && { snapshotId: params.snapshotId }), }) @@ -259,8 +298,8 @@ export class SessionManager implements Plugin, MultiAgentPlugin { const snapshot = agent.takeSnapshot({ preset: 'session' }) const snapshotId = uuidV7() await Promise.all([ - this._storage.snapshot.saveSnapshot({ location: this._location(agent), snapshotId, isLatest: false, snapshot }), - this._storage.snapshot.saveSnapshot({ + this._snapshotStorage.saveSnapshot({ location: this._location(agent), snapshotId, isLatest: false, snapshot }), + this._snapshotStorage.saveSnapshot({ location: this._location(agent), snapshotId: 'latest', isLatest: true, @@ -298,7 +337,7 @@ export class SessionManager implements Plugin, MultiAgentPlugin { this._multiAgentRestoredIds.add(event.orchestrator.id) const location = this._multiAgentLocation(event.orchestrator) - const snapshot = await this._storage.snapshot.loadSnapshot({ location }) + const snapshot = await this._snapshotStorage.loadSnapshot({ location }) if (!snapshot) return loadMultiAgentSnapshot(event.orchestrator as Graph | Swarm, snapshot, event.state) diff --git a/strands-ts/src/session/snapshot-storage-adapter.ts b/strands-ts/src/session/snapshot-storage-adapter.ts new file mode 100644 index 0000000000..70871e353a --- /dev/null +++ b/strands-ts/src/session/snapshot-storage-adapter.ts @@ -0,0 +1,187 @@ +/** + * Adapter that implements {@link SnapshotStorage} on top of the unified {@link Storage} interface. + * + * Allows the session manager to accept a unified Storage instance in addition to + * a legacy SnapshotStorage, bridging the two interfaces without breaking changes. + */ + +import type { Storage } from '../storage/storage.js' +import type { SnapshotStorage, SnapshotLocation } from './storage.js' +import type { Snapshot, SnapshotManifest } from './types.js' + +import { SessionError } from '../errors.js' +import { validateIdentifier, validateUuidV7 } from './validation.js' + +const SCHEMA_VERSION = '1.0' +const SNAPSHOT_REGEX = /snapshot_([\w-]+)\.json$/ + +/** + * Adapts a unified {@link Storage} instance into the {@link SnapshotStorage} interface + * expected by the session manager. + * + * Keys follow the same layout as the filesystem-based storage: + * `sessions//scopes///snapshots/...` + * + * @deprecated Remove in v2 when SnapshotStorage is dropped and SessionManager calls Storage directly. + * @internal + * @param storage - The unified Storage backend to delegate to + * @param basePrefix - Optional key prefix. Defaults to `'sessions'`. + */ +export class SnapshotStorageAdapter implements SnapshotStorage { + private readonly _storage: Storage + private readonly _basePrefix: string + + constructor(storage: Storage, basePrefix: string = 'sessions') { + this._storage = storage + this._basePrefix = basePrefix + } + + /** + * Persists a snapshot to storage. + * + * @param params - Snapshot location, ID, latest flag, and snapshot data + */ + async saveSnapshot(params: { + location: SnapshotLocation + snapshotId: string + isLatest: boolean + snapshot: Snapshot + }): Promise { + const key = params.isLatest + ? this._latestKey(params.location) + : this._historyKey(params.location, params.snapshotId) + await this._writeJSON(key, params.snapshot) + } + + /** + * Loads a snapshot from storage. + * + * @param params - Snapshot location and optional snapshot ID + * @returns The snapshot, or null if not found + */ + async loadSnapshot(params: { location: SnapshotLocation; snapshotId?: string }): Promise { + const key = + params.snapshotId === undefined + ? this._latestKey(params.location) + : this._historyKey(params.location, params.snapshotId) + return this._readJSON(key) + } + + /** + * Lists immutable snapshot IDs for a scope, sorted chronologically. + * + * @param params - Location, optional limit and cursor + * @returns Array of snapshot IDs + */ + async listSnapshotIds(params: { + location: SnapshotLocation + limit?: number + startAfter?: string + }): Promise { + if (params.limit !== undefined && params.limit <= 0) return [] + if (params.startAfter) validateUuidV7(params.startAfter) + + const prefix = this._historyPrefix(params.location) + const keys = await this._storage.list(prefix) + + let ids = keys + .map((key) => key.match(SNAPSHOT_REGEX)?.[1]) + .filter((id): id is string => id !== undefined) + .sort() + + if (params.startAfter) { + ids = ids.filter((id) => id > params.startAfter!) + } + if (params.limit !== undefined) { + ids = ids.slice(0, params.limit) + } + return ids + } + + /** + * Deletes all snapshots and data belonging to the session ID. + * + * @param params - Session ID to delete + */ + async deleteSession(params: { sessionId: string }): Promise { + validateIdentifier(params.sessionId) + const prefix = `${this._basePrefix}/${params.sessionId}/` + const keys = await this._storage.list(prefix) + const BATCH_SIZE = 100 + for (let i = 0; i < keys.length; i += BATCH_SIZE) { + await Promise.all(keys.slice(i, i + BATCH_SIZE).map((key) => this._storage.delete(key))) + } + } + + /** + * Loads the snapshot manifest for a scope. + * + * @param params - Snapshot location + * @returns The manifest, or a default if none exists + */ + async loadManifest(params: { location: SnapshotLocation }): Promise { + const key = this._manifestKey(params.location) + const manifest = await this._readJSON(key) + return ( + manifest ?? { + schemaVersion: SCHEMA_VERSION, + updatedAt: new Date().toISOString(), + } + ) + } + + /** + * Persists the snapshot manifest for a scope. + * + * @param params - Location and manifest data + */ + async saveManifest(params: { location: SnapshotLocation; manifest: SnapshotManifest }): Promise { + const key = this._manifestKey(params.location) + await this._writeJSON(key, params.manifest) + } + + private _scopePrefix(location: SnapshotLocation): string { + validateIdentifier(location.sessionId) + validateIdentifier(location.scopeId) + return `${this._basePrefix}/${location.sessionId}/scopes/${location.scope}/${location.scopeId}/snapshots` + } + + private _latestKey(location: SnapshotLocation): string { + return `${this._scopePrefix(location)}/snapshot_latest.json` + } + + private _historyKey(location: SnapshotLocation, snapshotId: string): string { + return `${this._scopePrefix(location)}/immutable_history/snapshot_${snapshotId}.json` + } + + private _historyPrefix(location: SnapshotLocation): string { + return `${this._scopePrefix(location)}/immutable_history/` + } + + private _manifestKey(location: SnapshotLocation): string { + return `${this._scopePrefix(location)}/manifest.json` + } + + private async _writeJSON(key: string, data: unknown): Promise { + try { + const bytes = new TextEncoder().encode(JSON.stringify(data)) + await this._storage.put(key, bytes) + } catch (error: unknown) { + throw new SessionError(`Failed to write '${key}' to storage`, { cause: error }) + } + } + + private async _readJSON(key: string): Promise { + try { + const bytes = await this._storage.get(key) + if (bytes === null) return null + const text = new TextDecoder().decode(bytes) + return JSON.parse(text) as T + } catch (error: unknown) { + if (error instanceof SyntaxError) { + throw new SessionError(`Corrupted JSON at '${key}'`, { cause: error }) + } + throw new SessionError(`Failed to read '${key}' from storage`, { cause: error }) + } + } +} diff --git a/strands-ts/src/session/storage.ts b/strands-ts/src/session/storage.ts index e663f9343a..b621b2022d 100644 --- a/strands-ts/src/session/storage.ts +++ b/strands-ts/src/session/storage.ts @@ -16,12 +16,8 @@ export type SnapshotLocation = { * SessionStorage configuration for pluggable storage backends. * Allows users to configure snapshot and transcript storage independently. * - * @example - * ```typescript - * const storage: SessionStorage = { - * snapshot: new S3Storage({ bucket: 'my-bucket' }) - * } - * ``` + * @deprecated Remove in v2 when SessionManager accepts only unified `Storage`. + * @internal Prefer passing a unified `Storage` directly to `SessionManagerConfig.storage`. */ export type SessionStorage = { snapshot: SnapshotStorage @@ -32,6 +28,10 @@ export type SessionStorage = { * Interface for snapshot persistence. * Implementations provide storage backends (S3, filesystem, etc.). * + * @deprecated Remove in v2 when SessionManager calls unified `Storage` directly. + * @internal This is an internal contract used by the session manager. Users should pass + * a unified `Storage` to `SessionManagerConfig.storage` instead of implementing this directly. + * * File layout convention: * ``` * sessions// diff --git a/strands-ts/src/storage/__tests__/in-memory-storage.test.ts b/strands-ts/src/storage/__tests__/in-memory-storage.test.ts new file mode 100644 index 0000000000..e64a15226d --- /dev/null +++ b/strands-ts/src/storage/__tests__/in-memory-storage.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { InMemoryStorage } from '../in-memory-storage.js' +import { StorageError } from '../../errors.js' + +describe('InMemoryStorage', () => { + let storage: InMemoryStorage + + beforeEach(() => { + storage = new InMemoryStorage({ maxEntries: null }) + }) + + describe('put', () => { + it('stores data under the given key', async () => { + const data = new TextEncoder().encode('hello') + await storage.put('test/key', data) + const result = await storage.get('test/key') + expect(result).toEqual(data) + }) + + it('overwrites existing data', async () => { + await storage.put('key', new TextEncoder().encode('first')) + await storage.put('key', new TextEncoder().encode('second')) + const result = await storage.get('key') + expect(new TextDecoder().decode(result!)).toBe('second') + }) + + it('copies bytes on put to prevent aliasing', async () => { + const data = new Uint8Array([1, 2, 3]) + await storage.put('key', data) + data[0] = 99 + const result = await storage.get('key') + expect(result![0]).toBe(1) + }) + }) + + describe('get', () => { + it('returns null for missing keys', async () => { + const result = await storage.get('nonexistent') + expect(result).toBeNull() + }) + + it('copies bytes on get to prevent aliasing', async () => { + await storage.put('key', new Uint8Array([1, 2, 3])) + const first = await storage.get('key') + first![0] = 99 + const second = await storage.get('key') + expect(second![0]).toBe(1) + }) + }) + + describe('delete', () => { + it('removes an existing key', async () => { + await storage.put('key', new Uint8Array([1])) + await storage.delete('key') + const result = await storage.get('key') + expect(result).toBeNull() + }) + + it('is a no-op for missing keys', async () => { + await expect(storage.delete('nonexistent')).resolves.toBeUndefined() + }) + }) + + describe('list', () => { + it('returns keys matching a prefix', async () => { + await storage.put('sessions/a/data', new Uint8Array([1])) + await storage.put('sessions/b/data', new Uint8Array([2])) + await storage.put('memory/notes', new Uint8Array([3])) + + const keys = await storage.list('sessions/') + expect(keys).toEqual(['sessions/a/data', 'sessions/b/data']) + }) + + it('returns all keys when prefix is empty', async () => { + await storage.put('a', new Uint8Array([1])) + await storage.put('b', new Uint8Array([2])) + + const keys = await storage.list('') + expect(keys).toEqual(['a', 'b']) + }) + + it('returns keys sorted lexicographically', async () => { + await storage.put('c', new Uint8Array([3])) + await storage.put('a', new Uint8Array([1])) + await storage.put('b', new Uint8Array([2])) + + const keys = await storage.list('') + expect(keys).toEqual(['a', 'b', 'c']) + }) + + it('returns empty array when no keys match', async () => { + await storage.put('other/key', new Uint8Array([1])) + const keys = await storage.list('sessions/') + expect(keys).toEqual([]) + }) + }) + + describe('clear', () => { + it('removes all entries', async () => { + await storage.put('a', new Uint8Array([1])) + await storage.put('b', new Uint8Array([2])) + storage.clear() + const keys = await storage.list('') + expect(keys).toEqual([]) + }) + }) + + describe('LRU eviction', () => { + it('evicts the least-recently-used entry when maxEntries is exceeded', async () => { + const bounded = new InMemoryStorage({ maxEntries: 2 }) + await bounded.put('a', new Uint8Array([1])) + await bounded.put('b', new Uint8Array([2])) + await bounded.put('c', new Uint8Array([3])) + + expect(await bounded.get('a')).toBeNull() + expect(await bounded.get('b')).not.toBeNull() + expect(await bounded.get('c')).not.toBeNull() + }) + + it('get promotes an entry so it is not evicted next', async () => { + const bounded = new InMemoryStorage({ maxEntries: 2 }) + await bounded.put('a', new Uint8Array([1])) + await bounded.put('b', new Uint8Array([2])) + await bounded.get('a') + await bounded.put('c', new Uint8Array([3])) + + expect(await bounded.get('a')).not.toBeNull() + expect(await bounded.get('b')).toBeNull() + expect(await bounded.get('c')).not.toBeNull() + }) + + it('works with maxEntries: 1', async () => { + const bounded = new InMemoryStorage({ maxEntries: 1 }) + await bounded.put('a', new Uint8Array([1])) + await bounded.put('b', new Uint8Array([2])) + + expect(await bounded.get('a')).toBeNull() + expect(await bounded.get('b')).not.toBeNull() + }) + + it('overwriting an existing key does not trigger eviction', async () => { + const bounded = new InMemoryStorage({ maxEntries: 2 }) + await bounded.put('a', new Uint8Array([1])) + await bounded.put('b', new Uint8Array([2])) + await bounded.put('a', new Uint8Array([99])) + + expect(await bounded.get('a')).toEqual(new Uint8Array([99])) + expect(await bounded.get('b')).not.toBeNull() + }) + + it('does not evict when maxEntries is null', async () => { + const unbounded = new InMemoryStorage({ maxEntries: null }) + for (let i = 0; i < 1000; i++) { + await unbounded.put(`key-${i}`, new Uint8Array([i])) + } + const keys = await unbounded.list('') + expect(keys).toHaveLength(1000) + }) + + it('rejects maxEntries less than 1', () => { + expect(() => new InMemoryStorage({ maxEntries: 0 })).toThrow() + expect(() => new InMemoryStorage({ maxEntries: -1 })).toThrow() + }) + + it('rejects non-integer maxEntries', () => { + expect(() => new InMemoryStorage({ maxEntries: 1.5 })).toThrow() + expect(() => new InMemoryStorage({ maxEntries: 0.9 })).toThrow() + }) + }) + + describe('key normalization', () => { + it('normalizes slashes so equivalent keys resolve to the same entry', async () => { + await storage.put('/a//b/', new Uint8Array([1])) + const result = await storage.get('a/b') + expect(result).toEqual(new Uint8Array([1])) + }) + + it('rejects empty keys', async () => { + await expect(storage.put('', new Uint8Array([1]))).rejects.toThrow(StorageError) + }) + + it('rejects keys with .. segments', async () => { + await expect(storage.put('a/../b', new Uint8Array([1]))).rejects.toThrow(StorageError) + }) + + it('rejects prefixes with .. segments', async () => { + await expect(storage.list('../')).rejects.toThrow(StorageError) + }) + }) +}) diff --git a/strands-ts/src/storage/__tests__/local-file-storage.test.node.ts b/strands-ts/src/storage/__tests__/local-file-storage.test.node.ts new file mode 100644 index 0000000000..42502d51be --- /dev/null +++ b/strands-ts/src/storage/__tests__/local-file-storage.test.node.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { LocalFileStorage } from '../local-file-storage.js' +import { rm, readFile, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' + +describe('LocalFileStorage', () => { + let baseDir: string + let storage: LocalFileStorage + + beforeEach(() => { + baseDir = join(tmpdir(), `strands-test-${randomUUID()}`) + storage = new LocalFileStorage(baseDir) + }) + + afterEach(async () => { + await rm(baseDir, { recursive: true, force: true }) + }) + + describe('put and get', () => { + it('round-trips bytes', async () => { + const data = new TextEncoder().encode('hello world') + await storage.put('test/file.txt', data) + const result = await storage.get('test/file.txt') + expect(result).toEqual(data) + }) + + it('creates nested directories', async () => { + await storage.put('deep/nested/path/file.bin', new Uint8Array([1, 2, 3])) + const info = await stat(join(baseDir, 'deep/nested/path/file.bin')) + expect(info.isFile()).toBe(true) + }) + + it('overwrites existing values', async () => { + await storage.put('key', new TextEncoder().encode('first')) + await storage.put('key', new TextEncoder().encode('second')) + const result = await storage.get('key') + expect(new TextDecoder().decode(result!)).toBe('second') + }) + + it('returns null for missing keys', async () => { + const result = await storage.get('nonexistent/key') + expect(result).toBeNull() + }) + + it('writes atomically via tmp file', async () => { + await storage.put('atomic/test', new Uint8Array([1])) + const content = await readFile(join(baseDir, 'atomic/test')) + expect(new Uint8Array(content)).toEqual(new Uint8Array([1])) + await expect(stat(join(baseDir, 'atomic/test.tmp'))).rejects.toThrow() + }) + }) + + describe('delete', () => { + it('removes an existing key', async () => { + await storage.put('deleteme', new Uint8Array([1])) + await storage.delete('deleteme') + const result = await storage.get('deleteme') + expect(result).toBeNull() + }) + + it('is a no-op for missing keys', async () => { + await expect(storage.delete('nonexistent')).resolves.toBeUndefined() + }) + }) + + describe('list', () => { + it('lists keys under a prefix', async () => { + await storage.put('sessions/a/data.json', new Uint8Array([1])) + await storage.put('sessions/b/data.json', new Uint8Array([2])) + await storage.put('memory/notes.json', new Uint8Array([3])) + + const keys = await storage.list('sessions/') + expect(keys).toEqual(['sessions/a/data.json', 'sessions/b/data.json']) + }) + + it('returns all keys for empty prefix', async () => { + await storage.put('a', new Uint8Array([1])) + await storage.put('b', new Uint8Array([2])) + + const keys = await storage.list('') + expect(keys).toEqual(['a', 'b']) + }) + + it('returns empty array when base directory does not exist', async () => { + const fresh = new LocalFileStorage(join(tmpdir(), `nonexistent-${randomUUID()}`)) + const keys = await fresh.list('') + expect(keys).toEqual([]) + }) + + it('excludes scratch files', async () => { + await storage.put('real', new Uint8Array([1])) + const { writeFile, mkdir } = await import('node:fs/promises') + await mkdir(baseDir, { recursive: true }) + await writeFile(join(baseDir, 'leftover.__strands_tmp'), 'garbage') + + const keys = await storage.list('') + expect(keys).not.toContain('leftover.__strands_tmp') + }) + + it('does not exclude user .tmp files', async () => { + await storage.put('notes.tmp', new Uint8Array([1])) + const keys = await storage.list('') + expect(keys).toContain('notes.tmp') + }) + + it('returns keys sorted lexicographically', async () => { + await storage.put('c', new Uint8Array([3])) + await storage.put('a', new Uint8Array([1])) + await storage.put('b', new Uint8Array([2])) + + const keys = await storage.list('') + expect(keys).toEqual(['a', 'b', 'c']) + }) + }) +}) diff --git a/strands-ts/src/storage/__tests__/s3-storage.test.ts b/strands-ts/src/storage/__tests__/s3-storage.test.ts new file mode 100644 index 0000000000..830e80f124 --- /dev/null +++ b/strands-ts/src/storage/__tests__/s3-storage.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { StorageError } from '../../errors.js' + +const mockSend = vi.fn() +const mockS3Client = vi.fn(function (this: { send: typeof mockSend }) { + this.send = mockSend +} as unknown as () => void) +const mockPutObjectCommand = vi.fn() +const mockGetObjectCommand = vi.fn() +const mockDeleteObjectCommand = vi.fn() +const mockListObjectsV2Command = vi.fn() + +vi.mock('@aws-sdk/client-s3', () => ({ + S3Client: mockS3Client, + PutObjectCommand: mockPutObjectCommand, + GetObjectCommand: mockGetObjectCommand, + DeleteObjectCommand: mockDeleteObjectCommand, + ListObjectsV2Command: mockListObjectsV2Command, +})) + +import { S3Storage } from '../s3-storage.js' + +describe('S3Storage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('constructor', () => { + it('throws when both s3Client and region are provided', () => { + const client = { send: vi.fn() } as never + expect(() => new S3Storage('bucket', { s3Client: client, region: 'us-west-2' })).toThrow(StorageError) + }) + + it('accepts just a bucket name', () => { + expect(() => new S3Storage('my-bucket')).not.toThrow() + }) + }) + + describe('put', () => { + it('sends a PutObjectCommand with the correct params', async () => { + mockSend.mockResolvedValue({}) + const storage = new S3Storage('my-bucket', { prefix: 'agents/' }) + const data = new TextEncoder().encode('payload') + + await storage.put('sessions/abc/data.json', data) + + expect(mockPutObjectCommand).toHaveBeenCalledWith({ + Bucket: 'my-bucket', + Key: 'agents/sessions/abc/data.json', + Body: data, + }) + expect(mockSend).toHaveBeenCalledTimes(1) + }) + + it('wraps SDK errors in StorageError', async () => { + mockSend.mockRejectedValue(new Error('AccessDenied')) + const storage = new S3Storage('my-bucket') + + await expect(storage.put('key', new Uint8Array([1]))).rejects.toThrow(StorageError) + }) + }) + + describe('get', () => { + it('returns bytes when the object exists', async () => { + const bytes = new Uint8Array([1, 2, 3]) + mockSend.mockResolvedValue({ Body: { transformToByteArray: () => Promise.resolve(bytes) } }) + const storage = new S3Storage('my-bucket') + + const result = await storage.get('some/key') + expect(result).toEqual(bytes) + }) + + it('returns null for NoSuchKey', async () => { + const error = new Error('NoSuchKey') + error.name = 'NoSuchKey' + mockSend.mockRejectedValue(error) + const storage = new S3Storage('my-bucket') + + const result = await storage.get('missing') + expect(result).toBeNull() + }) + + it('returns null for NotFound', async () => { + const error = new Error('NotFound') + error.name = 'NotFound' + mockSend.mockRejectedValue(error) + const storage = new S3Storage('my-bucket') + + const result = await storage.get('missing') + expect(result).toBeNull() + }) + + it('wraps other errors in StorageError', async () => { + mockSend.mockRejectedValue(new Error('NetworkFailure')) + const storage = new S3Storage('my-bucket') + + await expect(storage.get('key')).rejects.toThrow(StorageError) + }) + }) + + describe('delete', () => { + it('sends a DeleteObjectCommand', async () => { + mockSend.mockResolvedValue({}) + const storage = new S3Storage('my-bucket', { prefix: 'p/' }) + + await storage.delete('key') + + expect(mockDeleteObjectCommand).toHaveBeenCalledWith({ + Bucket: 'my-bucket', + Key: 'p/key', + }) + }) + + it('wraps errors in StorageError', async () => { + mockSend.mockRejectedValue(new Error('InternalError')) + const storage = new S3Storage('my-bucket') + + await expect(storage.delete('key')).rejects.toThrow(StorageError) + }) + }) + + describe('list', () => { + it('returns keys with prefix stripped', async () => { + mockSend.mockResolvedValue({ + Contents: [{ Key: 'prefix/a' }, { Key: 'prefix/b/c' }], + IsTruncated: false, + }) + const storage = new S3Storage('my-bucket', { prefix: 'prefix/' }) + + const keys = await storage.list('') + expect(keys).toEqual(['a', 'b/c']) + }) + + it('paginates until IsTruncated is false', async () => { + mockSend + .mockResolvedValueOnce({ + Contents: [{ Key: 'a' }], + IsTruncated: true, + NextContinuationToken: 'token1', + }) + .mockResolvedValueOnce({ + Contents: [{ Key: 'b' }], + IsTruncated: false, + }) + const storage = new S3Storage('my-bucket') + + const keys = await storage.list('') + expect(keys).toEqual(['a', 'b']) + expect(mockSend).toHaveBeenCalledTimes(2) + }) + + it('wraps errors in StorageError', async () => { + mockSend.mockRejectedValue(new Error('BucketNotFound')) + const storage = new S3Storage('my-bucket') + + await expect(storage.list('prefix/')).rejects.toThrow(StorageError) + }) + }) +}) diff --git a/strands-ts/src/storage/in-memory-storage.ts b/strands-ts/src/storage/in-memory-storage.ts new file mode 100644 index 0000000000..ebee50ae1e --- /dev/null +++ b/strands-ts/src/storage/in-memory-storage.ts @@ -0,0 +1,119 @@ +import type { Storage } from './storage.js' + +import { normalizeKey, normalizePrefix } from './normalize.js' + +/** Configuration for {@link InMemoryStorage}. */ +export interface InMemoryStorageConfig { + /** + * Maximum number of entries before LRU eviction kicks in. + * When a `put` would exceed this limit, the least-recently-accessed entry is evicted. + * Set to `null` for unbounded growth. + */ + maxEntries: number | null +} + +/** + * In-memory {@link Storage} backend backed by a `Map`. + * + * Useful for testing and for serverless environments where disk access is unavailable. + * Content does not survive process restarts — for persistence use {@link LocalFileStorage} + * or {@link S3Storage}. + * + * Eviction is LRU-based: when a `put` would exceed `maxEntries`, the least-recently-accessed + * entry is evicted. This is the only eviction mechanism — there is no turn-based or + * time-based expiration. When used with the `ContextOffloader` plugin, set `maxEntries` + * to control how many offloaded entries are retained (each offloaded content block uses + * exactly one key). + * + * Keys are normalized identically to {@link LocalFileStorage}: slash runs are collapsed, + * leading/trailing slashes are stripped, and `..` segments are rejected. + * + * @example + * ```typescript + * const storage = new InMemoryStorage({ maxEntries: 100 }) + * await storage.put('memory/notes.json', new TextEncoder().encode('[]')) + * const bytes = await storage.get('memory/notes.json') + * ``` + */ +export class InMemoryStorage implements Storage { + private readonly _store = new Map() + private readonly _maxEntries: number | null + + constructor(config: InMemoryStorageConfig) { + const maxEntries = config.maxEntries + if (maxEntries !== null && (!Number.isInteger(maxEntries) || maxEntries < 1)) { + throw new Error('maxEntries must be a positive integer') + } + this._maxEntries = maxEntries + } + + /** + * Stores `data` under `key`, overwriting any existing value. + * Bytes are copied on write to prevent aliasing with the caller's buffer. + * If `maxEntries` is set and the store is full, the least-recently-accessed entry is evicted. + * + * @param key - Opaque, `/`-separated key identifying the value + * @param data - Raw bytes to persist + * @throws {@link StorageError} if the key is empty or contains `..` segments + */ + async put(key: string, data: Uint8Array): Promise { + const normalized = normalizeKey(key) + this._store.delete(normalized) + if (this._maxEntries !== null && this._store.size >= this._maxEntries) { + const oldest = this._store.keys().next().value! + this._store.delete(oldest) + } + this._store.set(normalized, data.slice()) + } + + /** + * Retrieves the bytes previously stored under `key`. + * Returns a copy to prevent aliasing with the internal buffer. + * Accessing a key moves it to the most-recently-used position. + * + * @param key - The key to read + * @returns The stored bytes, or `null` if no value exists for `key` + * @throws {@link StorageError} if the key is empty or contains `..` segments + */ + async get(key: string): Promise { + const normalized = normalizeKey(key) + const value = this._store.get(normalized) + if (value === undefined) return null + this._store.delete(normalized) + this._store.set(normalized, value) + return value.slice() + } + + /** + * Deletes the value stored under `key`. A no-op if the key does not exist. + * + * @param key - The key to delete + * @throws {@link StorageError} if the key is empty or contains `..` segments + */ + async delete(key: string): Promise { + this._store.delete(normalizeKey(key)) + } + + /** + * Lists the keys whose names begin with `prefix`, sorted lexicographically. + * + * @param prefix - Key prefix to match. An empty string matches all keys. + * @returns The matching keys, sorted ascending + * @throws {@link StorageError} if the prefix contains `..` segments + */ + async list(prefix: string): Promise { + const normalized = normalizePrefix(prefix) + const keys: string[] = [] + for (const key of this._store.keys()) { + if (key.startsWith(normalized)) keys.push(key) + } + return keys.sort() + } + + /** + * Removes all stored entries. Useful for resetting state between tests. + */ + clear(): void { + this._store.clear() + } +} diff --git a/strands-ts/src/storage/index.ts b/strands-ts/src/storage/index.ts new file mode 100644 index 0000000000..902e9d3c89 --- /dev/null +++ b/strands-ts/src/storage/index.ts @@ -0,0 +1,20 @@ +/** + * Unified storage module. + * + * Provides the {@link Storage} interface and shipped implementations for persisting + * raw bytes under string keys. All SDK subsystems that need persistence — sessions, + * memory, context offloading, transcripts — consume this interface. + * + * @example + * ```typescript + * import { LocalFileStorage, InMemoryStorage } from '@strands-agents/sdk/storage' + * ``` + * + * @packageDocumentation + */ + +export type { Storage } from './storage.js' +export { InMemoryStorage } from './in-memory-storage.js' +export { LocalFileStorage } from './local-file-storage.js' +export { S3Storage } from './s3-storage.js' +export type { S3StorageConfig } from './s3-storage.js' diff --git a/strands-ts/src/storage/local-file-storage.ts b/strands-ts/src/storage/local-file-storage.ts new file mode 100644 index 0000000000..3fb90a59b2 --- /dev/null +++ b/strands-ts/src/storage/local-file-storage.ts @@ -0,0 +1,225 @@ +import type { Sandbox } from '../sandbox/base.js' +import type { Storage } from './storage.js' + +import { StorageError } from '../errors.js' +import { normalizeKey, normalizePrefix } from './normalize.js' + +/** + * Returns true if the error represents a missing file or directory (ENOENT). + * + * @param error - The caught error to inspect + * @returns Whether the error is a filesystem ENOENT error + */ +function isFileNotFoundError(error: unknown): boolean { + return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' +} + +/** + * Local-filesystem {@link Storage} backend. + * + * Persists each key as a file under a base directory, mapping the key's `/` segments + * onto directory segments. On the host filesystem, writes are atomic (write to a + * scratch sibling, then rename) so a crash mid-write never leaves a partially written + * file. When bound to a {@link Sandbox} via {@link forSandbox}, all I/O is routed + * through that sandbox instead of the host's `node:fs` (atomicity depends on the + * sandbox implementation). + * + * @example + * ```typescript + * import { LocalFileStorage } from '@strands-agents/sdk/storage' + * + * const storage = new LocalFileStorage('./.strands/storage') + * await storage.put('sessions/abc/snapshot.json', bytes) + * ``` + */ +export class LocalFileStorage implements Storage { + private readonly _baseDir: string + private readonly _sandbox: Sandbox | undefined + + /** + * @param baseDir - Root directory under which keys are stored. Defaults to `./.strands/storage`. + * @param sandbox - Optional sandbox to route I/O through. Usually set via {@link forSandbox}. + */ + constructor(baseDir: string = './.strands/storage', sandbox?: Sandbox) { + this._baseDir = baseDir + this._sandbox = sandbox + } + + /** + * Returns a storage instance whose I/O is routed through `sandbox`. + * + * Instances already bound to a sandbox return themselves unchanged. + * + * @param sandbox - Sandbox to route the returned instance's I/O through + * @returns A new `LocalFileStorage` with the same base directory, routed through `sandbox` + */ + forSandbox(sandbox: Sandbox): LocalFileStorage { + if (this._sandbox) return this + return new LocalFileStorage(this._baseDir, sandbox) + } + + /** + * Stores `data` under `key`, overwriting any existing value. + * + * @param key - Opaque, `/`-separated key identifying the value + * @param data - Raw bytes to persist + * @throws {@link StorageError} if the key is invalid or the write fails + */ + async put(key: string, data: Uint8Array): Promise { + const normalized = normalizeKey(key) + const path = this._pathFor(normalized) + if (this._sandbox) { + try { + await this._sandbox.writeFile(path, data) + } catch (error: unknown) { + throw new StorageError(`Failed to write '${normalized}' to sandbox storage`, { cause: error }) + } + return + } + try { + const { mkdir, writeFile, rename } = await import('node:fs/promises') + const { dirname } = await import('node:path') + await mkdir(dirname(path), { recursive: true }) + const { randomUUID } = await import('node:crypto') + const tmpPath = `${path}.__strands_tmp_${randomUUID()}` + await writeFile(tmpPath, data) + await rename(tmpPath, path) + } catch (error: unknown) { + throw new StorageError(`Failed to write '${normalized}' to local storage`, { cause: error }) + } + } + + /** + * Retrieves the bytes previously stored under `key`. + * + * @param key - The key to read + * @returns The stored bytes, or `null` if no value exists for `key` + * @throws {@link StorageError} if the key is invalid or the read fails + */ + async get(key: string): Promise { + const normalized = normalizeKey(key) + const path = this._pathFor(normalized) + if (this._sandbox) { + try { + return await this._sandbox.readFile(path) + } catch (error: unknown) { + if (isFileNotFoundError(error)) return null + throw new StorageError(`Failed to read '${normalized}' from sandbox storage`, { cause: error }) + } + } + try { + const { readFile } = await import('node:fs/promises') + const content = await readFile(path) + return new Uint8Array(content) + } catch (error: unknown) { + if (isFileNotFoundError(error)) return null + throw new StorageError(`Failed to read '${normalized}' from local storage`, { cause: error }) + } + } + + /** + * Deletes the value stored under `key`. A no-op if the key does not exist. + * + * @param key - The key to delete + * @throws {@link StorageError} if the key is invalid or the delete fails + */ + async delete(key: string): Promise { + const normalized = normalizeKey(key) + const path = this._pathFor(normalized) + if (this._sandbox) { + try { + await this._sandbox.removeFile(path) + } catch (error: unknown) { + if (!isFileNotFoundError(error)) { + throw new StorageError(`Failed to delete '${normalized}' from sandbox storage`, { cause: error }) + } + } + return + } + try { + const { rm } = await import('node:fs/promises') + await rm(path, { force: true }) + } catch (error: unknown) { + throw new StorageError(`Failed to delete '${normalized}' from local storage`, { cause: error }) + } + } + + /** + * Lists the keys whose names begin with `prefix`, sorted lexicographically. + * + * @param prefix - Key prefix to match. An empty string matches all keys. + * @returns The matching keys, sorted ascending + * @throws {@link StorageError} if the prefix is invalid or the listing fails + */ + async list(prefix: string): Promise { + const normalized = normalizePrefix(prefix) + const base = this._baseDir.replace(/\/+$/, '') + // Narrow the walk to the deepest directory the prefix fully specifies + const lastSlash = normalized.lastIndexOf('/') + const dirPortion = lastSlash >= 0 ? normalized.slice(0, lastSlash) : '' + const startDir = dirPortion ? `${base}/${dirPortion}` : base + const keys = this._sandbox + ? await this._listKeysSandbox(startDir, dirPortion) + : await this._listKeysHost(startDir, dirPortion) + return keys.filter((key) => key.startsWith(normalized)).sort() + } + + private _pathFor(key: string): string { + const base = this._baseDir.replace(/\/+$/, '') + return `${base}/${key}` + } + + private async _listKeysHost(dir: string, keyPrefix: string): Promise { + const { readdir } = await import('node:fs/promises') + + const walk = async (walkDir: string, walkPrefix: string): Promise => { + let entries + try { + entries = await readdir(walkDir, { withFileTypes: true }) + } catch (error: unknown) { + if (isFileNotFoundError(error)) return [] + throw new StorageError(`Failed to list local storage under '${walkPrefix}'`, { cause: error }) + } + const found: string[] = [] + for (const entry of entries) { + if (!entry.isDirectory() && entry.name.includes('.__strands_tmp')) continue + const childKey = walkPrefix ? `${walkPrefix}/${entry.name}` : entry.name + if (entry.isDirectory()) { + found.push(...(await walk(`${walkDir}/${entry.name}`, childKey))) + } else { + found.push(childKey) + } + } + return found + } + + return walk(dir, keyPrefix) + } + + private async _listKeysSandbox(dir: string, keyPrefix: string): Promise { + const sandbox = this._sandbox! + + const walk = async (walkDir: string, walkPrefix: string): Promise => { + let entries + try { + entries = await sandbox.listFiles(walkDir) + } catch (error: unknown) { + if (isFileNotFoundError(error)) return [] + throw new StorageError(`Failed to list sandbox storage under '${walkPrefix}'`, { cause: error }) + } + const found: string[] = [] + for (const entry of entries) { + if (!entry.isDir && entry.name.includes('.__strands_tmp')) continue + const childKey = walkPrefix ? `${walkPrefix}/${entry.name}` : entry.name + if (entry.isDir) { + found.push(...(await walk(`${walkDir}/${entry.name}`, childKey))) + } else { + found.push(childKey) + } + } + return found + } + + return walk(dir, keyPrefix) + } +} diff --git a/strands-ts/src/storage/normalize.ts b/strands-ts/src/storage/normalize.ts new file mode 100644 index 0000000000..df43cc5b1b --- /dev/null +++ b/strands-ts/src/storage/normalize.ts @@ -0,0 +1,36 @@ +import { StorageError } from '../errors.js' + +/** + * Validates and normalizes a storage key: collapses runs of `/`, strips leading + * and trailing `/`, and rejects empty keys and any `..` segment. + * + * @param key - The raw key to normalize + * @returns The normalized key + * @throws {@link StorageError} if the key is empty or contains a `..` segment + */ +export function normalizeKey(key: string): string { + const normalized = key.replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '') + if (normalized.length === 0) { + throw new StorageError('Storage key must not be empty') + } + if (normalized.split('/').includes('..')) { + throw new StorageError(`Invalid storage key '${key}': '..' path segments are not allowed`) + } + return normalized +} + +/** + * Normalizes a list prefix: collapses slash runs, strips leading slashes. + * Unlike a key, an empty prefix is valid and matches everything. + * + * @param prefix - The raw prefix to normalize + * @returns The normalized prefix + * @throws {@link StorageError} if the prefix contains a `..` segment + */ +export function normalizePrefix(prefix: string): string { + const normalized = prefix.replace(/\/+/g, '/').replace(/^\/+/, '') + if (normalized.split('/').includes('..')) { + throw new StorageError(`Invalid storage prefix '${prefix}': '..' path segments are not allowed`) + } + return normalized +} diff --git a/strands-ts/src/storage/s3-storage.ts b/strands-ts/src/storage/s3-storage.ts new file mode 100644 index 0000000000..47f32b701c --- /dev/null +++ b/strands-ts/src/storage/s3-storage.ts @@ -0,0 +1,160 @@ +import type { Storage } from './storage.js' + +import { StorageError } from '../errors.js' +import { normalizeKey, normalizePrefix } from './normalize.js' + +/** Configuration for {@link S3Storage}. */ +export interface S3StorageConfig { + /** Optional key prefix prepended to every key (a leading namespace within the bucket). */ + prefix?: string + /** AWS region override. When omitted, the SDK's standard resolution chain applies. Cannot be combined with `s3Client`. */ + region?: string + /** Pre-configured S3 client. Cannot be combined with `region`. */ + s3Client?: import('@aws-sdk/client-s3').S3Client +} + +const S3_PAGE_SIZE = 1000 + +/** + * Amazon S3 {@link Storage} backend. + * + * Stores each key as an S3 object under an optional prefix. The AWS SDK is loaded + * lazily on first use and declared as an optional peer dependency, so consumers that + * never construct an `S3Storage` are not required to install `@aws-sdk/client-s3`. + * + * @example + * ```typescript + * import { S3Storage } from '@strands-agents/sdk/storage' + * + * const storage = new S3Storage('my-bucket', { prefix: 'agents/' }) + * await storage.put('sessions/abc/snapshot.json', bytes) + * ``` + */ +export class S3Storage implements Storage { + private readonly _bucket: string + private readonly _prefix: string + private readonly _region: string | undefined + private _client: import('@aws-sdk/client-s3').S3Client | undefined + + /** + * @param bucket - Target S3 bucket name + * @param config - Optional prefix, region, or pre-configured client + * @throws {@link StorageError} if both `region` and `s3Client` are provided + */ + constructor(bucket: string, config?: S3StorageConfig) { + if (config?.s3Client && config.region) { + throw new StorageError('Cannot specify both s3Client and region. Configure the region on the S3Client instead.') + } + this._bucket = bucket + this._prefix = config?.prefix ? config.prefix.replace(/\/+$/, '') + '/' : '' + this._region = config?.region + this._client = config?.s3Client + } + + /** + * Stores `data` under `key`, overwriting any existing value. + * + * @param key - Opaque, `/`-separated key identifying the value + * @param data - Raw bytes to persist + * @throws {@link StorageError} if the key is invalid or the upload fails + */ + async put(key: string, data: Uint8Array): Promise { + const normalized = normalizeKey(key) + const client = await this._getClient() + const { PutObjectCommand } = await import('@aws-sdk/client-s3') + try { + await client.send(new PutObjectCommand({ Bucket: this._bucket, Key: this._objectKey(normalized), Body: data })) + } catch (error: unknown) { + throw new StorageError(`Failed to write '${normalized}' to S3 bucket '${this._bucket}'`, { cause: error }) + } + } + + /** + * Retrieves the bytes previously stored under `key`. + * + * @param key - The key to read + * @returns The stored bytes, or `null` if no value exists for `key` + * @throws {@link StorageError} if the key is invalid or the download fails + */ + async get(key: string): Promise { + const normalized = normalizeKey(key) + const client = await this._getClient() + const { GetObjectCommand } = await import('@aws-sdk/client-s3') + try { + const response = await client.send( + new GetObjectCommand({ Bucket: this._bucket, Key: this._objectKey(normalized) }) + ) + const body = await response.Body?.transformToByteArray() + return body ? new Uint8Array(body) : null + } catch (error: unknown) { + if (error instanceof Error && (error.name === 'NoSuchKey' || error.name === 'NotFound')) { + return null + } + throw new StorageError(`Failed to read '${normalized}' from S3 bucket '${this._bucket}'`, { cause: error }) + } + } + + /** + * Deletes the value stored under `key`. A no-op if the key does not exist. + * + * @param key - The key to delete + * @throws {@link StorageError} if the key is invalid or the delete request fails + */ + async delete(key: string): Promise { + const normalized = normalizeKey(key) + const client = await this._getClient() + const { DeleteObjectCommand } = await import('@aws-sdk/client-s3') + try { + await client.send(new DeleteObjectCommand({ Bucket: this._bucket, Key: this._objectKey(normalized) })) + } catch (error: unknown) { + throw new StorageError(`Failed to delete '${normalized}' from S3 bucket '${this._bucket}'`, { cause: error }) + } + } + + /** + * Lists the keys whose names begin with `prefix`, sorted lexicographically. + * + * @param prefix - Key prefix to match. An empty string matches all keys. + * @returns The matching keys, sorted ascending + * @throws {@link StorageError} if the prefix is invalid or the list request fails + */ + async list(prefix: string): Promise { + const normalized = normalizePrefix(prefix) + const client = await this._getClient() + const { ListObjectsV2Command } = await import('@aws-sdk/client-s3') + const listPrefix = `${this._prefix}${normalized}` + const keys: string[] = [] + let continuationToken: string | undefined + try { + do { + const response = await client.send( + new ListObjectsV2Command({ + Bucket: this._bucket, + Prefix: listPrefix, + MaxKeys: S3_PAGE_SIZE, + ContinuationToken: continuationToken, + }) + ) + for (const object of response.Contents ?? []) { + if (object.Key === undefined) continue + keys.push(this._prefix ? object.Key.slice(this._prefix.length) : object.Key) + } + continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined + } while (continuationToken) + } catch (error: unknown) { + throw new StorageError(`Failed to list S3 bucket '${this._bucket}' under '${normalized}'`, { cause: error }) + } + return keys.sort() + } + + private async _getClient(): Promise { + if (this._client) return this._client + const { S3Client } = await import('@aws-sdk/client-s3') + this._client = new S3Client(this._region ? { region: this._region } : {}) + return this._client + } + + private _objectKey(key: string): string { + return `${this._prefix}${key}` + } +} diff --git a/strands-ts/src/storage/storage.ts b/strands-ts/src/storage/storage.ts new file mode 100644 index 0000000000..190ffe9479 --- /dev/null +++ b/strands-ts/src/storage/storage.ts @@ -0,0 +1,49 @@ +/** + * A backend for storing and retrieving raw bytes under string keys. + * + * The interface is deliberately minimal — four operations over opaque `Uint8Array` + * values. Implementations must treat keys as opaque path-like strings (segments + * separated by `/`) and must round-trip the bytes they are given unchanged. + * + * Implement this to add a custom backend; the SDK ships {@link InMemoryStorage}, + * {@link LocalFileStorage}, and {@link S3Storage}. + */ +export interface Storage { + /** + * Stores `data` under `key`, overwriting any existing value. + * + * @param key - Opaque, `/`-separated key identifying the value + * @param data - Raw bytes to persist + * @throws {@link StorageError} if the write fails + */ + put(key: string, data: Uint8Array): Promise + + /** + * Retrieves the bytes previously stored under `key`. + * + * @param key - The key to read + * @returns The stored bytes, or `null` if no value exists for `key` + * @throws {@link StorageError} if the read fails for a reason other than a missing key + */ + get(key: string): Promise + + /** + * Deletes the value stored under `key`. A no-op if the key does not exist. + * + * @param key - The key to delete + * @throws {@link StorageError} if the delete fails + */ + delete(key: string): Promise + + /** + * Lists the keys whose names begin with `prefix`. + * + * Returns full keys (not the suffix after the prefix), sorted lexicographically. + * An empty `prefix` lists every key. + * + * @param prefix - Key prefix to match + * @returns The matching keys, sorted ascending + * @throws {@link StorageError} if the listing fails + */ + list(prefix: string): Promise +} diff --git a/strands-ts/src/vended-plugins/context-offloader/__tests__/plugin.test.ts b/strands-ts/src/vended-plugins/context-offloader/__tests__/plugin.test.ts index a0e73ef19b..2f3fd7b528 100644 --- a/strands-ts/src/vended-plugins/context-offloader/__tests__/plugin.test.ts +++ b/strands-ts/src/vended-plugins/context-offloader/__tests__/plugin.test.ts @@ -659,4 +659,38 @@ describe('ContextOffloader', () => { expect(() => hook.callback(event)).not.toThrow() }) }) + + describe('unified Storage content-type round-trip', () => { + it('stores content and content-type in a single framed key', async () => { + const { InMemoryStorage: UnifiedInMemoryStorage } = await import('../../../storage/in-memory-storage.js') + const unifiedStorage = new UnifiedInMemoryStorage({ maxEntries: null }) + + const plugin = new ContextOffloader({ + storage: unifiedStorage, + maxResultTokens: 10, + previewTokens: 5, + includeRetrievalTool: true, + }) + const agent = createMockAgent() + plugin.initAgent(agent) + + const event = makeEvent([new TextBlock('hello world '.repeat(100))]) + await invokeTrackedHook(agent, event) + + const preview = (event.result.content[0] as TextBlock).text + const refMatch = preview.match(/tool-123_0/) + expect(refMatch).not.toBeNull() + + // Only one key per block — no sidecar .contenttype key + const keys = await unifiedStorage.list('') + expect(keys).toEqual(['tool-123_0']) + + const tools = plugin.getTools() + const retrievalTool = tools[0]! + const result = await (retrievalTool as unknown as { invoke(input: unknown): Promise }).invoke({ + reference: 'tool-123_0', + }) + expect(result).toBe('hello world '.repeat(100)) + }) + }) }) diff --git a/strands-ts/src/vended-plugins/context-offloader/plugin.ts b/strands-ts/src/vended-plugins/context-offloader/plugin.ts index a6d8f4b104..2d731f0eab 100644 --- a/strands-ts/src/vended-plugins/context-offloader/plugin.ts +++ b/strands-ts/src/vended-plugins/context-offloader/plugin.ts @@ -10,9 +10,55 @@ import { tool } from '../../tools/tool-factory.js' import { z } from 'zod' import { logger } from '../../logging/logger.js' import type { JSONValue } from '../../types/json.js' -import { FileStorage, InMemoryStorage, type Storage } from './storage.js' +import { FileStorage, InMemoryStorage as LegacyInMemoryStorage, type Storage as OffloaderStorage } from './storage.js' +import type { Storage } from '../../storage/storage.js' import { isSearchableContent, searchContent } from './search.js' +function isOffloaderStorage(storage: Storage | OffloaderStorage): storage is OffloaderStorage { + return 'store' in storage && 'retrieve' in storage +} + +// Framed format for unified Storage: [2-byte contentType length BE][contentType UTF-8][content bytes] +// This keeps one storage key per offloaded block so content-type metadata doesn't consume maxEntries. +function frameContent(content: Uint8Array, contentType: string): Uint8Array { + const ctBytes = new TextEncoder().encode(contentType) + const frame = new Uint8Array(2 + ctBytes.length + content.length) + frame[0] = (ctBytes.length >> 8) & 0xff + frame[1] = ctBytes.length & 0xff + frame.set(ctBytes, 2) + frame.set(content, 2 + ctBytes.length) + return frame +} + +function unframeContent(frame: Uint8Array): { content: Uint8Array; contentType: string } { + const ctLen = (frame[0]! << 8) | frame[1]! + const contentType = new TextDecoder().decode(frame.subarray(2, 2 + ctLen)) + const content = frame.subarray(2 + ctLen) + return { content, contentType } +} + +async function storeContent( + storage: Storage | OffloaderStorage, + key: string, + content: Uint8Array, + contentType?: string +): Promise { + if (isOffloaderStorage(storage)) return storage.store(key, content, contentType) + const ct = contentType ?? 'application/octet-stream' + await storage.put(key, frameContent(content, ct)) + return key +} + +async function retrieveContent( + storage: Storage | OffloaderStorage, + reference: string +): Promise<{ content: Uint8Array; contentType: string }> { + if (isOffloaderStorage(storage)) return storage.retrieve(reference) + const data = await storage.get(reference) + if (data === null) throw new Error(`Reference not found: ${reference}`) + return unframeContent(data) +} + const CHARS_PER_TOKEN = 4 const DEFAULT_MAX_RESULT_TOKENS = 2_500 const DEFAULT_PREVIEW_TOKENS = 1_000 @@ -103,8 +149,19 @@ function decodeStoredContent(content: Uint8Array, contentType: string, reference /** Configuration for the {@link ContextOffloader} plugin. */ export interface ContextOffloaderConfig { - /** Storage backend for persisting offloaded content. */ - storage: Storage + /** + * Storage backend for persisting offloaded content. + * + * Accepts either: + * - A unified `Storage` (from `@strands-agents/sdk/storage`) — each offloaded content block + * occupies exactly one key (content-type is framed into the stored bytes). Eviction is + * controlled by the storage's own `maxEntries` (LRU). No turn-based eviction is performed. + * - A legacy offloader `Storage` (deprecated, from this module) — manages its own turn-based + * eviction internally via its `evictAfterTurns` constructor parameter. + * + * When omitted, the plugin uses the agent-level storage provided via `initStorage`. + */ + storage?: Storage | OffloaderStorage /** Token threshold above which tool results are offloaded. Defaults to 2,500. */ maxResultTokens?: number /** Number of tokens to keep as an inline preview. Defaults to 1,000. */ @@ -120,27 +177,45 @@ export interface ContextOffloaderConfig { * each content block to a storage backend and replaces the in-context result with * a truncated text preview plus per-block storage references. * + * ## Eviction behavior + * + * How offloaded entries are evicted depends on the storage backend: + * + * - **Unified `Storage`** (from `@strands-agents/sdk/storage`): eviction is handled by the + * storage implementation itself via LRU (`maxEntries`). The offloader does not track turns + * or perform any time-based cleanup. Configure `maxEntries` on the storage to control + * how many offloaded entries are retained. + * + * - **Legacy offloader storage** (deprecated `InMemoryStorage` from this module): the storage + * manages its own turn-based eviction internally. Entries not accessed within + * `evictAfterTurns` model invocation cycles are automatically removed. + * * @example * ```typescript - * import { ContextOffloader, InMemoryStorage } from '@strands-agents/sdk/vended-plugins/context-offloader' + * import { ContextOffloader } from '@strands-agents/sdk/vended-plugins/context-offloader' + * import { InMemoryStorage } from '@strands-agents/sdk/storage' * * const agent = new Agent({ * model, - * plugins: [new ContextOffloader({ storage: new InMemoryStorage() })], + * plugins: [new ContextOffloader({ + * storage: new InMemoryStorage({ maxEntries: 200 }), + * })], * }) * ``` */ export class ContextOffloader implements Plugin { readonly name = 'strands:context-offloader' - private readonly _storage: Storage + // Assigned in constructor or initStorage, then effectively final + private _storage: Storage | OffloaderStorage | undefined private readonly _maxResultTokens: number private readonly _previewTokens: number private readonly _includeRetrievalTool: boolean - private readonly _storageByAgent = new WeakMap() + private readonly _storageByAgent = new WeakMap() private _retrievalTool: Tool | undefined + private readonly _hasExplicitStorage: boolean - constructor(config: ContextOffloaderConfig) { + constructor(config: ContextOffloaderConfig = {}) { const maxResultTokens = config.maxResultTokens ?? DEFAULT_MAX_RESULT_TOKENS const previewTokens = config.previewTokens ?? DEFAULT_PREVIEW_TOKENS @@ -149,21 +224,40 @@ export class ContextOffloader implements Plugin { if (previewTokens >= maxResultTokens) throw new Error('previewTokens must be less than maxResultTokens') this._storage = config.storage + this._hasExplicitStorage = config.storage !== undefined this._maxResultTokens = maxResultTokens this._previewTokens = previewTokens this._includeRetrievalTool = config.includeRetrievalTool ?? true } + /** + * Receives the agent-level unified storage when no explicit storage was provided. + * + * @param storage - The agent-level storage instance + */ + initStorage(storage: Storage): void { + if (this._hasExplicitStorage) return + this._storage = storage + } + initAgent(agent: LocalAgent): void { - if (this._storage instanceof InMemoryStorage) { + if (!this._storage) { + throw new Error( + 'ContextOffloader requires a storage backend. ' + + 'Pass storage in the plugin config or set storage on the Agent config.' + ) + } + if (this._storage instanceof LegacyInMemoryStorage) { this._storage._bind(agent) } this._storageForAgent(agent) agent.addHook(AfterToolCallEvent, (event) => this._handleToolResult(event)) + // Legacy backends manage their own turn-based eviction internally. + // Unified Storage backends rely on LRU (maxEntries) for eviction — no turn tracking needed. let cycleCount = 0 agent.addHook(BeforeModelCallEvent, () => { cycleCount++ - if (this._storage instanceof InMemoryStorage) { + if (this._storage instanceof LegacyInMemoryStorage) { this._storage._evict(cycleCount) } }) @@ -175,8 +269,8 @@ export class ContextOffloader implements Plugin { return [this._retrievalTool] } - private _storageForAgent(agent: LocalAgent): Storage { - if (!(this._storage instanceof FileStorage)) return this._storage + private _storageForAgent(agent: LocalAgent): Storage | OffloaderStorage { + if (!(this._storage instanceof FileStorage)) return this._storage! let storage = this._storageByAgent.get(agent) if (!storage) { @@ -186,8 +280,8 @@ export class ContextOffloader implements Plugin { return storage } - private _storageForToolContext(context?: ToolContext): Storage { - if (!(this._storage instanceof FileStorage)) return this._storage + private _storageForToolContext(context?: ToolContext): Storage | OffloaderStorage { + if (!(this._storage instanceof FileStorage)) return this._storage! if (!context) { throw new Error('FileStorage retrieval requires a tool execution context.') } @@ -218,7 +312,7 @@ export class ContextOffloader implements Plugin { callback: async (input, context) => { try { const storage = this._storageForToolContext(context) - const result = await storage.retrieve(input.reference) + const result = await retrieveContent(storage, input.reference) if (!input.pattern && !input.line_range && input.context_lines === undefined) { return decodeStoredContent(result.content, result.contentType, input.reference) @@ -246,18 +340,18 @@ export class ContextOffloader implements Plugin { } private async _storeBlock( - storage: Storage, + storage: Storage | OffloaderStorage, block: ToolResultContent, key: string ): Promise<{ ref: string; contentType: string; description: string }> { if (block instanceof TextBlock && block.text) { - const ref = await storage.store(key, new TextEncoder().encode(block.text), 'text/plain') + const ref = await storeContent(storage, key, new TextEncoder().encode(block.text), 'text/plain') return { ref, contentType: 'text/plain', description: `text, ${block.text.length.toLocaleString()} chars` } } if (block instanceof JsonBlock) { const jsonStr = JSON.stringify(block.json, null, 2) const jsonBytes = new TextEncoder().encode(jsonStr) - const ref = await storage.store(key, jsonBytes, 'application/json') + const ref = await storeContent(storage, key, jsonBytes, 'application/json') return { ref, contentType: 'application/json', description: `json, ${jsonBytes.length.toLocaleString()} bytes` } } if (block instanceof ImageBlock || block instanceof VideoBlock || block instanceof DocumentBlock) { @@ -270,7 +364,7 @@ export class ContextOffloader implements Plugin { : `application/${block.format}` const label = block instanceof DocumentBlock ? block.name : contentType if (bytes) { - const ref = await storage.store(key, bytes, contentType) + const ref = await storeContent(storage, key, bytes, contentType) return { ref, contentType, description: `${label}, ${bytes.length.toLocaleString()} bytes` } } return { ref: '', contentType, description: `${label}, 0 bytes` } @@ -342,7 +436,6 @@ export class ContextOffloader implements Plugin { logger.warn(`tool_use_id=<${toolUseId}> | failed to offload tool result, keeping original`, err) return } - logger.debug( `tool_use_id=<${toolUseId}>, blocks=<${references.length}>, tokens=<${tokenCount}> | tool result offloaded` ) diff --git a/strands-ts/src/vended-plugins/context-offloader/storage.ts b/strands-ts/src/vended-plugins/context-offloader/storage.ts index a3bbd883cc..2d901effe1 100644 --- a/strands-ts/src/vended-plugins/context-offloader/storage.ts +++ b/strands-ts/src/vended-plugins/context-offloader/storage.ts @@ -1,9 +1,9 @@ /** - * Storage backends for offloaded tool result content. + * Legacy storage backends for offloaded tool result content. * - * This module defines the {@link Storage} interface and provides three built-in - * implementations: {@link InMemoryStorage}, {@link FileStorage}, and {@link S3Storage}. - * Each content block from a tool result is stored individually with its content type preserved. + * @deprecated Use the unified `Storage` from `@strands-agents/sdk/storage` instead. + * This module's `Storage` interface and its implementations (`InMemoryStorage`, + * `FileStorage`, `S3Storage`) are retained for backwards compatibility only. */ import type { Sandbox } from '../../sandbox/base.js' @@ -13,8 +13,11 @@ import { logger } from '../../logging/logger.js' * Backend for storing and retrieving offloaded content blocks. * * Implement this interface to create custom storage backends (e.g., Redis, DynamoDB). - * The SDK ships three built-in implementations: {@link InMemoryStorage}, - * {@link FileStorage}, and {@link S3Storage}. + * The SDK ships three built-in implementations: `InMemoryStorage`, + * `FileStorage`, and `S3Storage`. + * + * @deprecated Use the unified `Storage` from `@strands-agents/sdk/storage` interface instead. + * Pass it directly to `ContextOffloaderConfig.storage` — the plugin adapts it internally. */ export interface Storage { /** @@ -74,6 +77,8 @@ function stripTrailingSlashes(value: string): string { * Evicted entries are permanently deleted from memory. The agent will receive * an error if it attempts to retrieve evicted content. * + * @deprecated Pass a unified `Storage` from `@strands-agents/sdk/storage` (e.g. + * `InMemoryStorage` from `@strands-agents/sdk/storage`) to the ContextOffloader instead. * @param evictAfterTurns - Cycles of inactivity before eviction. Defaults to 20. `null` disables. */ export class InMemoryStorage implements Storage { @@ -162,6 +167,9 @@ export class InMemoryStorage implements Storage { * * When used by {@link ContextOffloader} without an explicit sandbox, FileStorage is * bound to each agent's sandbox, which may be the default NotASandboxLocalEnvironment. + * + * @deprecated Pass a unified `Storage` from `@strands-agents/sdk/storage` (e.g. + * `LocalFileStorage` from `@strands-agents/sdk/storage`) to the ContextOffloader instead. */ export interface FileStorageOptions { /** Directory path where artifact files will be stored. Defaults to `./artifacts`. */ @@ -170,6 +178,9 @@ export interface FileStorageOptions { sandbox?: Sandbox } +/** + * @deprecated Use `LocalFileStorage` from `@strands-agents/sdk/storage` instead. + */ export class FileStorage implements Storage { private static readonly METADATA_FILE = '.metadata.json' private readonly _artifactDir: string @@ -346,6 +357,8 @@ export class FileStorage implements Storage { * Stores offloaded content as S3 objects. Content type is preserved as S3 object metadata. * References are `s3://` URIs for direct access via AWS CLI or SDK. * + * @deprecated Pass a unified `Storage` from `@strands-agents/sdk/storage` (e.g. + * `S3Storage` from `@strands-agents/sdk/storage`) to the ContextOffloader instead. * @param bucket - S3 bucket name * @param options - Optional configuration (prefix, region, pre-configured S3Client) */ From 0a1af62aff674b08ea4b6ed5fc817689b7e01391 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:37:51 -0400 Subject: [PATCH 02/15] style: fix agent.ts formatting for prettier 3.9 --- strands-ts/src/agent/agent.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/strands-ts/src/agent/agent.ts b/strands-ts/src/agent/agent.ts index 9b39d142e6..fcc9b48375 100644 --- a/strands-ts/src/agent/agent.ts +++ b/strands-ts/src/agent/agent.ts @@ -727,9 +727,7 @@ export class Agent implements LocalAgent, InvokableAgent { | MiddlewareWrapPhase | MiddlewareOutputPhase, handler: - | MiddlewareHandler - | MiddlewareInputHandler - | MiddlewareOutputHandler + MiddlewareHandler | MiddlewareInputHandler | MiddlewareOutputHandler ): () => void { if ('_phase' in stageOrPhase) { const phase = stageOrPhase as { _phase: MiddlewarePhaseKind; _stage: MiddlewareStage } From efcb70dbe1297b8c2daeb64e5f7ffb91a11912de Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:31:29 -0400 Subject: [PATCH 03/15] refactor(storage): simplify interface and move eviction to consumers - Make Storage generic: Storage so backends can widen the list query type (e.g. DynamoDB partition/sort-key filters) - Strip InMemoryStorage to a plain unbounded Map (no LRU, no config) - Remove initStorage from Plugin interface and PluginRegistry - Remove storage from AgentConfig (plugins take storage in constructor) - Add cycle-based eviction to ContextOffloader using agent.metrics.cycleCount with configurable evictAfterCycles (default 20, null disables) - Add metrics: AgentMetrics to LocalAgent interface - Update snapshot-storage-adapter default prefix to 'session' - Add eviction tests for unified Storage path --- package-lock.json | 8 +- strands-ts/src/__fixtures__/agent-helpers.ts | 2 + strands-ts/src/agent/agent.ts | 19 ++-- strands-ts/src/plugins/plugin.ts | 15 ---- strands-ts/src/plugins/registry.ts | 16 ---- .../snapshot-storage-adapter.test.ts | 20 ++--- strands-ts/src/session/session-manager.ts | 14 --- .../src/session/snapshot-storage-adapter.ts | 6 +- .../__tests__/in-memory-storage.test.ts | 64 +------------- strands-ts/src/storage/in-memory-storage.ts | 43 ++-------- strands-ts/src/storage/local-file-storage.ts | 6 +- strands-ts/src/storage/storage.ts | 21 +++-- strands-ts/src/types/agent.ts | 6 ++ .../__tests__/plugin.test.ts | 80 ++++++++++++++++- .../context-offloader/plugin.ts | 86 ++++++++++++------- 15 files changed, 190 insertions(+), 216 deletions(-) diff --git a/package-lock.json b/package-lock.json index 452c653926..ac226f933a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5577,10 +5577,9 @@ } }, "node_modules/prettier": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.1.tgz", - "integrity": "sha512-ppiDo2CSwexck1eyZUwJHg/N3nf1+6IRCv7W/VJ5vaLnVCmB7+3CdRfMwoCHBBX6xTrREDTksZ4OZl5SSf4zXA==", - "dev": true, + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -6541,6 +6540,7 @@ "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1069.0", "@types/json-schema": "^7.0.15", + "prettier": "^3.9.4", "uuid": "^14.0.1", "yaml": "^2.8.3" }, diff --git a/strands-ts/src/__fixtures__/agent-helpers.ts b/strands-ts/src/__fixtures__/agent-helpers.ts index bec4c58a4e..6239939aaf 100644 --- a/strands-ts/src/__fixtures__/agent-helpers.ts +++ b/strands-ts/src/__fixtures__/agent-helpers.ts @@ -16,6 +16,7 @@ import { defaultSandbox } from '../sandbox/default.js' import type { Sandbox } from '../sandbox/base.js' import type { HookableEvent, StreamEvent } from '../hooks/events.js' import type { HookableEventConstructor, HookCallback } from '../hooks/types.js' +import { AgentMetrics } from '../telemetry/meter.js' import { expectLoopMetrics, type LoopMetricsMatcher } from './metrics-helpers.js' /** @@ -69,6 +70,7 @@ export function createMockAgent(data?: MockAgentData): MockAgent { modelState: new StateStore(), toolRegistry: data?.toolRegistry ?? new ToolRegistry(), cancelSignal: new AbortController().signal, + metrics: data?.extra?.metrics ?? new AgentMetrics(), // Mirror the real Agent.sandbox getter: resolve the environment default lazily. // An explicit `extra.sandbox` below overrides this accessor. get sandbox(): Sandbox { diff --git a/strands-ts/src/agent/agent.ts b/strands-ts/src/agent/agent.ts index fcc9b48375..10c3f7fbf8 100644 --- a/strands-ts/src/agent/agent.ts +++ b/strands-ts/src/agent/agent.ts @@ -104,7 +104,7 @@ import { MemoryManager } from '../memory/memory-manager.js' import type { MemoryManagerConfig } from '../memory/index.js' import { SessionManager } from '../session/session-manager.js' import { Tracer } from '../telemetry/tracer.js' -import { Meter } from '../telemetry/meter.js' +import { AgentMetrics, Meter } from '../telemetry/meter.js' import type { AttributeValue } from '@opentelemetry/api' import { logger } from '../logging/logger.js' import { CancelledError } from '../errors.js' @@ -119,7 +119,6 @@ import type { TakeSnapshotOptions } from './snapshot.js' import type { Snapshot } from '../types/snapshot.js' import type { Sandbox } from '../sandbox/base.js' import { defaultSandbox } from '../sandbox/default.js' -import type { Storage } from '../storage/storage.js' import { summarizeContextTool, truncateContextTool, @@ -296,12 +295,6 @@ export type AgentConfig = { * Defaults to `'concurrent'`. See {@link ToolExecutorStrategy} for details. */ toolExecutor?: ToolExecutorStrategy - /** - * Unified storage backend shared with all plugins that implement `initStorage`. - * Plugins receive this instance during initialization and use it for persistence - * rather than requiring the user to pass storage to each plugin separately. - */ - storage?: Storage /** * Execution environment for running commands, code, and file operations. * When provided, sandbox-aware tools route operations through it. @@ -467,6 +460,10 @@ export class Agent implements LocalAgent, InvokableAgent { return this._sandbox || defaultSandbox.get() } + get metrics(): AgentMetrics { + return this._meter.metrics + } + private readonly _hooksRegistry: HookRegistryImplementation private readonly _middlewareRegistry: MiddlewareRegistry private readonly _pluginRegistry: PluginRegistry @@ -576,7 +573,7 @@ export class Agent implements LocalAgent, InvokableAgent { ...((config?.contextManager === 'auto' || config?.contextManager === 'agentic') && !hasOffloader ? [ new ContextOffloader({ - storage: new InMemoryStorage({ maxEntries: 200 }), + storage: new InMemoryStorage(), maxResultTokens: config?.contextManager === 'agentic' ? AGENTIC_CONTEXT_MANAGER_MAX_RESULT_TOKENS @@ -590,10 +587,6 @@ export class Agent implements LocalAgent, InvokableAgent { new ModelPlugin(this.model), ]) - if (config?.storage) { - this._pluginRegistry.setStorage(config.storage) - } - if (config?.systemPrompt !== undefined) { this.systemPrompt = systemPromptFromData(config.systemPrompt) } diff --git a/strands-ts/src/plugins/plugin.ts b/strands-ts/src/plugins/plugin.ts index c59ddcbe88..b270875ecb 100644 --- a/strands-ts/src/plugins/plugin.ts +++ b/strands-ts/src/plugins/plugin.ts @@ -5,7 +5,6 @@ * add behavior changes to agents through hook registration and custom initialization. */ -import type { Storage } from '../storage/storage.js' import type { Tool } from '../tools/tool.js' import type { LocalAgent } from '../types/agent.js' @@ -68,20 +67,6 @@ export interface Plugin { */ initAgent(agent: LocalAgent): void | Promise - /** - * Receives the agent's configured storage instance. - * - * Called by the plugin registry during initialization when the agent has a - * `storage` configured. Plugins that need persistence should use this storage - * instance rather than requiring the user to pass storage separately. - * - * A plugin that already has storage configured via its constructor should - * ignore this call (constructor override takes priority). - * - * @param storage - The agent-level storage instance - */ - initStorage?(storage: Storage): void | Promise - /** * Returns tools provided by this plugin for auto-registration. * Implement to provide plugin-specific tools. diff --git a/strands-ts/src/plugins/registry.ts b/strands-ts/src/plugins/registry.ts index 95c5ddb480..ea6c3c79cf 100644 --- a/strands-ts/src/plugins/registry.ts +++ b/strands-ts/src/plugins/registry.ts @@ -2,7 +2,6 @@ * Plugin registry for managing plugins attached to an agent. */ -import type { Storage } from '../storage/storage.js' import type { Plugin } from './plugin.js' import type { LocalAgent } from '../types/agent.js' @@ -15,22 +14,12 @@ import type { LocalAgent } from '../types/agent.js' export class PluginRegistry { private readonly _plugins: Map private readonly _pending: Plugin[] - private _storage: Storage | undefined constructor(plugins: Plugin[] = []) { this._plugins = new Map() this._pending = [...plugins] } - /** - * Sets the storage instance to distribute to plugins during initialization. - * - * @param storage - The agent-level storage instance - */ - setStorage(storage: Storage): void { - this._storage = storage - } - /** * Initialize all pending plugins with the agent. * Safe to call multiple times — only runs once per pending batch. @@ -55,11 +44,6 @@ export class PluginRegistry { agent.toolRegistry.add(tools) } - // initStorage runs first so plugins can fall back to agent-level storage before initAgent wires hooks - if (this._storage && plugin.initStorage) { - await plugin.initStorage(this._storage) - } - await plugin.initAgent(agent) } } diff --git a/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts b/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts index 377dbf5309..b71c0afef4 100644 --- a/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts +++ b/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts @@ -26,7 +26,7 @@ describe('SnapshotStorageAdapter', () => { let adapter: SnapshotStorageAdapter beforeEach(() => { - backend = new InMemoryStorage({ maxEntries: null }) + backend = new InMemoryStorage() adapter = new SnapshotStorageAdapter(backend) }) @@ -37,7 +37,7 @@ describe('SnapshotStorageAdapter', () => { await adapter.saveSnapshot({ location, snapshotId: uuidV7(1), isLatest: true, snapshot }) - const keys = await backend.list('sessions/') + const keys = await backend.list('session/') expect(keys).toContainEqual(expect.stringContaining('snapshot_latest.json')) }) @@ -48,7 +48,7 @@ describe('SnapshotStorageAdapter', () => { await adapter.saveSnapshot({ location, snapshotId: id, isLatest: false, snapshot }) - const keys = await backend.list('sessions/') + const keys = await backend.list('session/') expect(keys).toContainEqual(expect.stringContaining(`immutable_history/snapshot_${id}.json`)) }) @@ -185,7 +185,7 @@ describe('SnapshotStorageAdapter', () => { await adapter.deleteSession({ sessionId: 'test-session' }) - const keys = await backend.list('sessions/test-session/') + const keys = await backend.list('session/test-session/') expect(keys).toHaveLength(0) }) @@ -208,8 +208,8 @@ describe('SnapshotStorageAdapter', () => { await adapter.deleteSession({ sessionId: 'session-1' }) - const keys1 = await backend.list('sessions/session-1/') - const keys2 = await backend.list('sessions/session-2/') + const keys1 = await backend.list('session/session-1/') + const keys2 = await backend.list('session/session-2/') expect(keys1).toHaveLength(0) expect(keys2.length).toBeGreaterThan(0) }) @@ -252,7 +252,7 @@ describe('SnapshotStorageAdapter', () => { snapshot: createTestSnapshot(), }) - const defaultKeys = await backend.list('sessions/') + const defaultKeys = await backend.list('session/') const customKeys = await backend.list('custom/prefix/') expect(defaultKeys).toHaveLength(0) expect(customKeys.length).toBeGreaterThan(0) @@ -261,7 +261,7 @@ describe('SnapshotStorageAdapter', () => { describe('error handling', () => { it('wraps storage write errors in SessionError', async () => { - const failingBackend: InMemoryStorage = new InMemoryStorage({ maxEntries: null }) + const failingBackend: InMemoryStorage = new InMemoryStorage() failingBackend.put = async () => { throw new Error('disk full') } @@ -274,7 +274,7 @@ describe('SnapshotStorageAdapter', () => { }) it('wraps storage read errors in SessionError', async () => { - const failingBackend: InMemoryStorage = new InMemoryStorage({ maxEntries: null }) + const failingBackend: InMemoryStorage = new InMemoryStorage() failingBackend.get = async () => { throw new Error('network timeout') } @@ -286,7 +286,7 @@ describe('SnapshotStorageAdapter', () => { it('throws SessionError on corrupted JSON', async () => { const location = createLocation() - const key = 'sessions/test-session/scopes/agent/test-agent/snapshots/snapshot_latest.json' + const key = 'session/test-session/scopes/agent/test-agent/snapshots/snapshot_latest.json' await backend.put(key, new TextEncoder().encode('not valid json{{{')) await expect(adapter.loadSnapshot({ location })).rejects.toThrow(SessionError) diff --git a/strands-ts/src/session/session-manager.ts b/strands-ts/src/session/session-manager.ts index 41a483618c..00f3c936c2 100644 --- a/strands-ts/src/session/session-manager.ts +++ b/strands-ts/src/session/session-manager.ts @@ -62,8 +62,6 @@ export interface SessionManagerConfig { * Accepts either: * - A unified {@link Storage} instance (recommended) — wrapped internally with {@link SnapshotStorageAdapter} * - A legacy `{ snapshot: SnapshotStorage }` object for backwards compatibility - * - * When omitted, the session manager receives storage from the agent via `initStorage`. */ storage?: Storage | { snapshot: SnapshotStorage } /** Unique session identifier. Defaults to `'default-session'`. */ @@ -105,7 +103,6 @@ export class SessionManager implements Plugin, MultiAgentPlugin { private readonly _snapshotTrigger?: SnapshotTriggerCallback | undefined private readonly _multiAgentSaveLatestOn: MultiAgentSaveLatestStrategy private _multiAgentRestoredIds = new Set() - private readonly _hasExplicitStorage: boolean /** * Unique identifier for this plugin. @@ -117,22 +114,11 @@ export class SessionManager implements Plugin, MultiAgentPlugin { constructor(config: SessionManagerConfig = {}) { this._sessionId = validateIdentifier(config.sessionId ?? 'default-session') this._storage = config.storage ? { snapshot: this._resolveSnapshotStorage(config.storage) } : undefined - this._hasExplicitStorage = config.storage !== undefined this._saveLatestOn = config.saveLatestOn ?? 'invocation' this._multiAgentSaveLatestOn = config.multiAgentSaveLatestOn ?? 'node' this._snapshotTrigger = config.snapshotTrigger } - /** - * Receives the agent-level unified storage when no explicit storage was provided. - * - * @param storage - The agent-level storage instance - */ - initStorage(storage: Storage): void { - if (this._hasExplicitStorage) return - this._storage = { snapshot: new SnapshotStorageAdapter(storage) } - } - private get _snapshotStorage(): SnapshotStorage { if (!this._storage) { throw new SessionError('SessionManager storage not initialized') diff --git a/strands-ts/src/session/snapshot-storage-adapter.ts b/strands-ts/src/session/snapshot-storage-adapter.ts index 70871e353a..c60e1910f7 100644 --- a/strands-ts/src/session/snapshot-storage-adapter.ts +++ b/strands-ts/src/session/snapshot-storage-adapter.ts @@ -20,18 +20,18 @@ const SNAPSHOT_REGEX = /snapshot_([\w-]+)\.json$/ * expected by the session manager. * * Keys follow the same layout as the filesystem-based storage: - * `sessions//scopes///snapshots/...` + * `session//scopes///snapshots/...` * * @deprecated Remove in v2 when SnapshotStorage is dropped and SessionManager calls Storage directly. * @internal * @param storage - The unified Storage backend to delegate to - * @param basePrefix - Optional key prefix. Defaults to `'sessions'`. + * @param basePrefix - Optional key prefix. Defaults to `'session'`. */ export class SnapshotStorageAdapter implements SnapshotStorage { private readonly _storage: Storage private readonly _basePrefix: string - constructor(storage: Storage, basePrefix: string = 'sessions') { + constructor(storage: Storage, basePrefix: string = 'session') { this._storage = storage this._basePrefix = basePrefix } diff --git a/strands-ts/src/storage/__tests__/in-memory-storage.test.ts b/strands-ts/src/storage/__tests__/in-memory-storage.test.ts index e64a15226d..b9c924fdb5 100644 --- a/strands-ts/src/storage/__tests__/in-memory-storage.test.ts +++ b/strands-ts/src/storage/__tests__/in-memory-storage.test.ts @@ -6,7 +6,7 @@ describe('InMemoryStorage', () => { let storage: InMemoryStorage beforeEach(() => { - storage = new InMemoryStorage({ maxEntries: null }) + storage = new InMemoryStorage() }) describe('put', () => { @@ -105,68 +105,6 @@ describe('InMemoryStorage', () => { }) }) - describe('LRU eviction', () => { - it('evicts the least-recently-used entry when maxEntries is exceeded', async () => { - const bounded = new InMemoryStorage({ maxEntries: 2 }) - await bounded.put('a', new Uint8Array([1])) - await bounded.put('b', new Uint8Array([2])) - await bounded.put('c', new Uint8Array([3])) - - expect(await bounded.get('a')).toBeNull() - expect(await bounded.get('b')).not.toBeNull() - expect(await bounded.get('c')).not.toBeNull() - }) - - it('get promotes an entry so it is not evicted next', async () => { - const bounded = new InMemoryStorage({ maxEntries: 2 }) - await bounded.put('a', new Uint8Array([1])) - await bounded.put('b', new Uint8Array([2])) - await bounded.get('a') - await bounded.put('c', new Uint8Array([3])) - - expect(await bounded.get('a')).not.toBeNull() - expect(await bounded.get('b')).toBeNull() - expect(await bounded.get('c')).not.toBeNull() - }) - - it('works with maxEntries: 1', async () => { - const bounded = new InMemoryStorage({ maxEntries: 1 }) - await bounded.put('a', new Uint8Array([1])) - await bounded.put('b', new Uint8Array([2])) - - expect(await bounded.get('a')).toBeNull() - expect(await bounded.get('b')).not.toBeNull() - }) - - it('overwriting an existing key does not trigger eviction', async () => { - const bounded = new InMemoryStorage({ maxEntries: 2 }) - await bounded.put('a', new Uint8Array([1])) - await bounded.put('b', new Uint8Array([2])) - await bounded.put('a', new Uint8Array([99])) - - expect(await bounded.get('a')).toEqual(new Uint8Array([99])) - expect(await bounded.get('b')).not.toBeNull() - }) - - it('does not evict when maxEntries is null', async () => { - const unbounded = new InMemoryStorage({ maxEntries: null }) - for (let i = 0; i < 1000; i++) { - await unbounded.put(`key-${i}`, new Uint8Array([i])) - } - const keys = await unbounded.list('') - expect(keys).toHaveLength(1000) - }) - - it('rejects maxEntries less than 1', () => { - expect(() => new InMemoryStorage({ maxEntries: 0 })).toThrow() - expect(() => new InMemoryStorage({ maxEntries: -1 })).toThrow() - }) - - it('rejects non-integer maxEntries', () => { - expect(() => new InMemoryStorage({ maxEntries: 1.5 })).toThrow() - expect(() => new InMemoryStorage({ maxEntries: 0.9 })).toThrow() - }) - }) describe('key normalization', () => { it('normalizes slashes so equivalent keys resolve to the same entry', async () => { diff --git a/strands-ts/src/storage/in-memory-storage.ts b/strands-ts/src/storage/in-memory-storage.ts index ebee50ae1e..e1ab49bb9a 100644 --- a/strands-ts/src/storage/in-memory-storage.ts +++ b/strands-ts/src/storage/in-memory-storage.ts @@ -2,16 +2,6 @@ import type { Storage } from './storage.js' import { normalizeKey, normalizePrefix } from './normalize.js' -/** Configuration for {@link InMemoryStorage}. */ -export interface InMemoryStorageConfig { - /** - * Maximum number of entries before LRU eviction kicks in. - * When a `put` would exceed this limit, the least-recently-accessed entry is evicted. - * Set to `null` for unbounded growth. - */ - maxEntries: number | null -} - /** * In-memory {@link Storage} backend backed by a `Map`. * @@ -19,68 +9,45 @@ export interface InMemoryStorageConfig { * Content does not survive process restarts — for persistence use {@link LocalFileStorage} * or {@link S3Storage}. * - * Eviction is LRU-based: when a `put` would exceed `maxEntries`, the least-recently-accessed - * entry is evicted. This is the only eviction mechanism — there is no turn-based or - * time-based expiration. When used with the `ContextOffloader` plugin, set `maxEntries` - * to control how many offloaded entries are retained (each offloaded content block uses - * exactly one key). + * This is a plain unbounded store with no eviction. Consumers that need eviction + * (e.g. the ContextOffloader plugin) manage it themselves. * * Keys are normalized identically to {@link LocalFileStorage}: slash runs are collapsed, * leading/trailing slashes are stripped, and `..` segments are rejected. * * @example * ```typescript - * const storage = new InMemoryStorage({ maxEntries: 100 }) + * const storage = new InMemoryStorage() * await storage.put('memory/notes.json', new TextEncoder().encode('[]')) * const bytes = await storage.get('memory/notes.json') * ``` */ export class InMemoryStorage implements Storage { private readonly _store = new Map() - private readonly _maxEntries: number | null - - constructor(config: InMemoryStorageConfig) { - const maxEntries = config.maxEntries - if (maxEntries !== null && (!Number.isInteger(maxEntries) || maxEntries < 1)) { - throw new Error('maxEntries must be a positive integer') - } - this._maxEntries = maxEntries - } /** * Stores `data` under `key`, overwriting any existing value. * Bytes are copied on write to prevent aliasing with the caller's buffer. - * If `maxEntries` is set and the store is full, the least-recently-accessed entry is evicted. * * @param key - Opaque, `/`-separated key identifying the value * @param data - Raw bytes to persist * @throws {@link StorageError} if the key is empty or contains `..` segments */ async put(key: string, data: Uint8Array): Promise { - const normalized = normalizeKey(key) - this._store.delete(normalized) - if (this._maxEntries !== null && this._store.size >= this._maxEntries) { - const oldest = this._store.keys().next().value! - this._store.delete(oldest) - } - this._store.set(normalized, data.slice()) + this._store.set(normalizeKey(key), data.slice()) } /** * Retrieves the bytes previously stored under `key`. * Returns a copy to prevent aliasing with the internal buffer. - * Accessing a key moves it to the most-recently-used position. * * @param key - The key to read * @returns The stored bytes, or `null` if no value exists for `key` * @throws {@link StorageError} if the key is empty or contains `..` segments */ async get(key: string): Promise { - const normalized = normalizeKey(key) - const value = this._store.get(normalized) + const value = this._store.get(normalizeKey(key)) if (value === undefined) return null - this._store.delete(normalized) - this._store.set(normalized, value) return value.slice() } diff --git a/strands-ts/src/storage/local-file-storage.ts b/strands-ts/src/storage/local-file-storage.ts index 3fb90a59b2..a7f01f80ab 100644 --- a/strands-ts/src/storage/local-file-storage.ts +++ b/strands-ts/src/storage/local-file-storage.ts @@ -28,7 +28,7 @@ function isFileNotFoundError(error: unknown): boolean { * ```typescript * import { LocalFileStorage } from '@strands-agents/sdk/storage' * - * const storage = new LocalFileStorage('./.strands/storage') + * const storage = new LocalFileStorage('./.strands/') * await storage.put('sessions/abc/snapshot.json', bytes) * ``` */ @@ -37,10 +37,10 @@ export class LocalFileStorage implements Storage { private readonly _sandbox: Sandbox | undefined /** - * @param baseDir - Root directory under which keys are stored. Defaults to `./.strands/storage`. + * @param baseDir - Root directory under which keys are stored. Defaults to `./.strands/`. * @param sandbox - Optional sandbox to route I/O through. Usually set via {@link forSandbox}. */ - constructor(baseDir: string = './.strands/storage', sandbox?: Sandbox) { + constructor(baseDir: string = './.strands/', sandbox?: Sandbox) { this._baseDir = baseDir this._sandbox = sandbox } diff --git a/strands-ts/src/storage/storage.ts b/strands-ts/src/storage/storage.ts index 190ffe9479..ed30c4043b 100644 --- a/strands-ts/src/storage/storage.ts +++ b/strands-ts/src/storage/storage.ts @@ -5,10 +5,15 @@ * values. Implementations must treat keys as opaque path-like strings (segments * separated by `/`) and must round-trip the bytes they are given unchanged. * + * The `ListQuery` type parameter controls what `list` accepts. It defaults to + * `string` (a key prefix), which every backend supports. Implementations may + * widen it to accept a richer query object (e.g. a DynamoDB partition/sort-key + * filter) while still accepting a plain string for SDK-internal callers. + * * Implement this to add a custom backend; the SDK ships {@link InMemoryStorage}, * {@link LocalFileStorage}, and {@link S3Storage}. */ -export interface Storage { +export interface Storage { /** * Stores `data` under `key`, overwriting any existing value. * @@ -36,14 +41,18 @@ export interface Storage { delete(key: string): Promise /** - * Lists the keys whose names begin with `prefix`. + * Lists keys matching the given query. + * + * When `ListQuery` is `string` (the default), this is a prefix match — returns + * full keys (not the suffix after the prefix), sorted lexicographically. An empty + * string lists every key. * - * Returns full keys (not the suffix after the prefix), sorted lexicographically. - * An empty `prefix` lists every key. + * Implementations may accept richer query objects (e.g. partition + sort-key filters) + * while still supporting a plain string prefix for SDK-internal callers. * - * @param prefix - Key prefix to match + * @param query - A string prefix or backend-specific query object * @returns The matching keys, sorted ascending * @throws {@link StorageError} if the listing fails */ - list(prefix: string): Promise + list(query: ListQuery): Promise } diff --git a/strands-ts/src/types/agent.ts b/strands-ts/src/types/agent.ts index d333f1ccd4..d9431a6130 100644 --- a/strands-ts/src/types/agent.ts +++ b/strands-ts/src/types/agent.ts @@ -280,6 +280,12 @@ export interface LocalAgent { */ readonly sandbox: Sandbox + /** + * Aggregated metrics for the agent's loop execution. + * Tracks cycle counts, token usage, tool execution stats, and model latency. + */ + readonly metrics: AgentMetrics + /** * The model provider used by the agent for inference. */ diff --git a/strands-ts/src/vended-plugins/context-offloader/__tests__/plugin.test.ts b/strands-ts/src/vended-plugins/context-offloader/__tests__/plugin.test.ts index 2f3fd7b528..f09347be12 100644 --- a/strands-ts/src/vended-plugins/context-offloader/__tests__/plugin.test.ts +++ b/strands-ts/src/vended-plugins/context-offloader/__tests__/plugin.test.ts @@ -6,6 +6,7 @@ import { TextBlock, JsonBlock, ToolResultBlock } from '../../../types/messages.j import { ImageBlock, VideoBlock, DocumentBlock } from '../../../types/media.js' import { createMockAgent, invokeTrackedHook } from '../../../__fixtures__/agent-helpers.js' import { MockMessageModel } from '../../../__fixtures__/mock-message-model.js' +import { AgentMetrics } from '../../../telemetry/meter.js' const mockModel = new MockMessageModel() @@ -614,8 +615,10 @@ describe('ContextOffloader', () => { describe('eviction via BeforeModelCallEvent', () => { it('calls _evict on storage with incrementing cycle count', () => { const storage = new InMemoryStorage(5) + let cycleCount = 0 const plugin = new ContextOffloader({ storage, maxResultTokens: 10, previewTokens: 5 }) const agent = createMockAgent() + Object.defineProperty(agent, 'metrics', { get: () => new AgentMetrics({ cycleCount: ++cycleCount }) }) plugin.initAgent(agent) const hook = agent.trackedHooks.find((h) => h.eventType === BeforeModelCallEvent)! @@ -630,8 +633,10 @@ describe('ContextOffloader', () => { it('evicts stale entries on BeforeModelCallEvent', async () => { const storage = new InMemoryStorage(2) + let cycleCount = 0 const plugin = new ContextOffloader({ storage, maxResultTokens: 10, previewTokens: 5 }) const agent = createMockAgent() + Object.defineProperty(agent, 'metrics', { get: () => new AgentMetrics({ cycleCount: ++cycleCount }) }) plugin.initAgent(agent) const ref = await storage.store('key1', new TextEncoder().encode('test')) @@ -660,10 +665,83 @@ describe('ContextOffloader', () => { }) }) + describe('unified Storage eviction', () => { + it('evicts entries stored more than evictAfterCycles ago', async () => { + const { InMemoryStorage: UnifiedInMemoryStorage } = await import('../../../storage/in-memory-storage.js') + const unifiedStorage = new UnifiedInMemoryStorage() + + let cycleCount = 0 + const plugin = new ContextOffloader({ + storage: unifiedStorage, + maxResultTokens: 10, + previewTokens: 5, + evictAfterCycles: 3, + }) + const agent = createMockAgent({ extra: { model: mockModel } as never }) + Object.defineProperty(agent, 'metrics', { get: () => new AgentMetrics({ cycleCount }) }) + plugin.initAgent(agent) + + // Store content at cycle 0 + const event = makeEvent([new TextBlock('x'.repeat(1000))]) + Object.defineProperty(event, 'agent', { value: agent }) + await invokeTrackedHook(agent, event) + + // Verify stored + const keys = await unifiedStorage.list('') + expect(keys.length).toBe(1) + + // Advance to cycle 3 — threshold = 3 - 3 = 0, stored at 0, 0 < 0 is false → not evicted + cycleCount = 3 + const hook = agent.trackedHooks.find((h) => h.eventType === BeforeModelCallEvent)! + const modelEvent = new BeforeModelCallEvent({ agent, model: mockModel, invocationState: {} }) + await hook.callback(modelEvent) + + const keysAfter3 = await unifiedStorage.list('') + expect(keysAfter3.length).toBe(1) + + // Advance to cycle 4 — threshold = 4 - 3 = 1, stored at 0, 0 < 1 → evicted + cycleCount = 4 + await hook.callback(modelEvent) + + const keysAfter4 = await unifiedStorage.list('') + expect(keysAfter4.length).toBe(0) + }) + + it('does not evict when evictAfterCycles is null', async () => { + const { InMemoryStorage: UnifiedInMemoryStorage } = await import('../../../storage/in-memory-storage.js') + const unifiedStorage = new UnifiedInMemoryStorage() + + let cycleCount = 0 + const plugin = new ContextOffloader({ + storage: unifiedStorage, + maxResultTokens: 10, + previewTokens: 5, + evictAfterCycles: null, + }) + const agent = createMockAgent({ extra: { model: mockModel } as never }) + Object.defineProperty(agent, 'metrics', { get: () => new AgentMetrics({ cycleCount }) }) + plugin.initAgent(agent) + + // Store content at cycle 0 + const event = makeEvent([new TextBlock('x'.repeat(1000))]) + Object.defineProperty(event, 'agent', { value: agent }) + await invokeTrackedHook(agent, event) + + // Advance far beyond any reasonable eviction window + cycleCount = 100 + const hook = agent.trackedHooks.find((h) => h.eventType === BeforeModelCallEvent)! + const modelEvent = new BeforeModelCallEvent({ agent, model: mockModel, invocationState: {} }) + await hook.callback(modelEvent) + + const keys = await unifiedStorage.list('') + expect(keys.length).toBe(1) + }) + }) + describe('unified Storage content-type round-trip', () => { it('stores content and content-type in a single framed key', async () => { const { InMemoryStorage: UnifiedInMemoryStorage } = await import('../../../storage/in-memory-storage.js') - const unifiedStorage = new UnifiedInMemoryStorage({ maxEntries: null }) + const unifiedStorage = new UnifiedInMemoryStorage() const plugin = new ContextOffloader({ storage: unifiedStorage, diff --git a/strands-ts/src/vended-plugins/context-offloader/plugin.ts b/strands-ts/src/vended-plugins/context-offloader/plugin.ts index 2d731f0eab..8913307687 100644 --- a/strands-ts/src/vended-plugins/context-offloader/plugin.ts +++ b/strands-ts/src/vended-plugins/context-offloader/plugin.ts @@ -154,12 +154,11 @@ export interface ContextOffloaderConfig { * * Accepts either: * - A unified `Storage` (from `@strands-agents/sdk/storage`) — each offloaded content block - * occupies exactly one key (content-type is framed into the stored bytes). Eviction is - * controlled by the storage's own `maxEntries` (LRU). No turn-based eviction is performed. + * occupies exactly one key (content-type is framed into the stored bytes). * - A legacy offloader `Storage` (deprecated, from this module) — manages its own turn-based * eviction internally via its `evictAfterTurns` constructor parameter. * - * When omitted, the plugin uses the agent-level storage provided via `initStorage`. + * Required — must be provided in the plugin constructor. */ storage?: Storage | OffloaderStorage /** Token threshold above which tool results are offloaded. Defaults to 2,500. */ @@ -168,6 +167,13 @@ export interface ContextOffloaderConfig { previewTokens?: number /** Whether to register the `retrieve_offloaded_content` tool. Defaults to true. */ includeRetrievalTool?: boolean + /** + * Number of agent loop cycles before an offloaded entry is evicted. + * Entries stored more than this many cycles ago are deleted. + * Defaults to 20. Set to `null` to disable eviction. + * Only applies to unified `Storage` backends — legacy backends manage their own eviction. + */ + evictAfterCycles?: number | null } /** @@ -181,10 +187,9 @@ export interface ContextOffloaderConfig { * * How offloaded entries are evicted depends on the storage backend: * - * - **Unified `Storage`** (from `@strands-agents/sdk/storage`): eviction is handled by the - * storage implementation itself via LRU (`maxEntries`). The offloader does not track turns - * or perform any time-based cleanup. Configure `maxEntries` on the storage to control - * how many offloaded entries are retained. + * - **Unified `Storage`** (from `@strands-agents/sdk/storage`): the plugin tracks each + * stored key's last-access cycle (from `agent.metrics.cycleCount`). Entries not accessed + * within `evictAfterCycles` agent loop cycles are deleted. Defaults to 20 cycles. * * - **Legacy offloader storage** (deprecated `InMemoryStorage` from this module): the storage * manages its own turn-based eviction internally. Entries not accessed within @@ -198,7 +203,7 @@ export interface ContextOffloaderConfig { * const agent = new Agent({ * model, * plugins: [new ContextOffloader({ - * storage: new InMemoryStorage({ maxEntries: 200 }), + * storage: new InMemoryStorage(), * })], * }) * ``` @@ -206,14 +211,17 @@ export interface ContextOffloaderConfig { export class ContextOffloader implements Plugin { readonly name = 'strands:context-offloader' - // Assigned in constructor or initStorage, then effectively final - private _storage: Storage | OffloaderStorage | undefined + private static readonly _DEFAULT_EVICT_AFTER_CYCLES = 20 + + private readonly _storage: Storage | OffloaderStorage | undefined private readonly _maxResultTokens: number private readonly _previewTokens: number private readonly _includeRetrievalTool: boolean + private readonly _evictAfterCycles: number | null private readonly _storageByAgent = new WeakMap() + private readonly _keyStoredAt = new Map() + private _agent: LocalAgent | undefined private _retrievalTool: Tool | undefined - private readonly _hasExplicitStorage: boolean constructor(config: ContextOffloaderConfig = {}) { const maxResultTokens = config.maxResultTokens ?? DEFAULT_MAX_RESULT_TOKENS @@ -223,46 +231,58 @@ export class ContextOffloader implements Plugin { if (previewTokens < 0) throw new Error('previewTokens must be non-negative') if (previewTokens >= maxResultTokens) throw new Error('previewTokens must be less than maxResultTokens') + const evictAfterCycles = config.evictAfterCycles === undefined + ? ContextOffloader._DEFAULT_EVICT_AFTER_CYCLES + : config.evictAfterCycles + if (evictAfterCycles !== null && evictAfterCycles < 1) { + throw new Error('evictAfterCycles must be a positive integer') + } + this._storage = config.storage - this._hasExplicitStorage = config.storage !== undefined this._maxResultTokens = maxResultTokens this._previewTokens = previewTokens this._includeRetrievalTool = config.includeRetrievalTool ?? true - } - - /** - * Receives the agent-level unified storage when no explicit storage was provided. - * - * @param storage - The agent-level storage instance - */ - initStorage(storage: Storage): void { - if (this._hasExplicitStorage) return - this._storage = storage + this._evictAfterCycles = evictAfterCycles } initAgent(agent: LocalAgent): void { if (!this._storage) { - throw new Error( - 'ContextOffloader requires a storage backend. ' + - 'Pass storage in the plugin config or set storage on the Agent config.' - ) + throw new Error('ContextOffloader requires a storage backend. Pass storage in the plugin config.') } if (this._storage instanceof LegacyInMemoryStorage) { this._storage._bind(agent) } + this._agent = agent this._storageForAgent(agent) agent.addHook(AfterToolCallEvent, (event) => this._handleToolResult(event)) - // Legacy backends manage their own turn-based eviction internally. - // Unified Storage backends rely on LRU (maxEntries) for eviction — no turn tracking needed. - let cycleCount = 0 + agent.addHook(BeforeModelCallEvent, () => { - cycleCount++ + const cycleCount = agent.metrics.cycleCount if (this._storage instanceof LegacyInMemoryStorage) { this._storage._evict(cycleCount) + } else if (this._evictAfterCycles !== null) { + this._evict(cycleCount) } }) } + private _evict(currentCycle: number): void { + const threshold = currentCycle - this._evictAfterCycles! + const toEvict: string[] = [] + for (const [key, lastAccess] of this._keyStoredAt) { + if (lastAccess < threshold) { + toEvict.push(key) + } + } + if (toEvict.length === 0) return + const storage = this._storage as Storage + for (const key of toEvict) { + this._keyStoredAt.delete(key) + storage.delete(key).catch(() => {}) + } + logger.debug(`evicted=<${toEvict.length}>, threshold=<${threshold}> | evicted stale offloaded entries`) + } + getTools(): Tool[] { if (!this._includeRetrievalTool) return [] if (!this._retrievalTool) this._retrievalTool = this._createRetrievalTool() @@ -432,6 +452,12 @@ export class ContextOffloader implements Plugin { try { const storage = this._storageForAgent(event.agent) references = await Promise.all(content.map((block, i) => this._storeBlock(storage, block, `${toolUseId}_${i}`))) + if (!isOffloaderStorage(storage)) { + const storedAt = this._agent!.metrics.cycleCount + for (const entry of references) { + if (entry.ref) this._keyStoredAt.set(entry.ref, storedAt) + } + } } catch (err) { logger.warn(`tool_use_id=<${toolUseId}> | failed to offload tool result, keeping original`, err) return From 4378875c0dfdfde1bb6a8a3bec8b58f5cc35a2ca Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:47:30 -0400 Subject: [PATCH 04/15] fix(context-offloader): address review feedback on eviction - Fix docs: eviction is based on store-time, not last-access - Use event.agent.metrics.cycleCount instead of this._agent (multi-agent safe) - Remove unused _agent field - Add Number.isInteger validation for evictAfterCycles --- .../src/vended-plugins/context-offloader/plugin.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/strands-ts/src/vended-plugins/context-offloader/plugin.ts b/strands-ts/src/vended-plugins/context-offloader/plugin.ts index 8913307687..5e7b0d1dbd 100644 --- a/strands-ts/src/vended-plugins/context-offloader/plugin.ts +++ b/strands-ts/src/vended-plugins/context-offloader/plugin.ts @@ -187,9 +187,9 @@ export interface ContextOffloaderConfig { * * How offloaded entries are evicted depends on the storage backend: * - * - **Unified `Storage`** (from `@strands-agents/sdk/storage`): the plugin tracks each - * stored key's last-access cycle (from `agent.metrics.cycleCount`). Entries not accessed - * within `evictAfterCycles` agent loop cycles are deleted. Defaults to 20 cycles. + * - **Unified `Storage`** (from `@strands-agents/sdk/storage`): the plugin records the + * cycle count (from `agent.metrics.cycleCount`) when each key is stored. Entries stored + * more than `evictAfterCycles` cycles ago are deleted. Defaults to 20 cycles. * * - **Legacy offloader storage** (deprecated `InMemoryStorage` from this module): the storage * manages its own turn-based eviction internally. Entries not accessed within @@ -220,7 +220,6 @@ export class ContextOffloader implements Plugin { private readonly _evictAfterCycles: number | null private readonly _storageByAgent = new WeakMap() private readonly _keyStoredAt = new Map() - private _agent: LocalAgent | undefined private _retrievalTool: Tool | undefined constructor(config: ContextOffloaderConfig = {}) { @@ -234,7 +233,7 @@ export class ContextOffloader implements Plugin { const evictAfterCycles = config.evictAfterCycles === undefined ? ContextOffloader._DEFAULT_EVICT_AFTER_CYCLES : config.evictAfterCycles - if (evictAfterCycles !== null && evictAfterCycles < 1) { + if (evictAfterCycles !== null && (!Number.isInteger(evictAfterCycles) || evictAfterCycles < 1)) { throw new Error('evictAfterCycles must be a positive integer') } @@ -252,7 +251,6 @@ export class ContextOffloader implements Plugin { if (this._storage instanceof LegacyInMemoryStorage) { this._storage._bind(agent) } - this._agent = agent this._storageForAgent(agent) agent.addHook(AfterToolCallEvent, (event) => this._handleToolResult(event)) @@ -453,7 +451,7 @@ export class ContextOffloader implements Plugin { const storage = this._storageForAgent(event.agent) references = await Promise.all(content.map((block, i) => this._storeBlock(storage, block, `${toolUseId}_${i}`))) if (!isOffloaderStorage(storage)) { - const storedAt = this._agent!.metrics.cycleCount + const storedAt = event.agent.metrics.cycleCount for (const entry of references) { if (entry.ref) this._keyStoredAt.set(entry.ref, storedAt) } From c4c732ad520d5a4674cefa6355aaa8ae7386875b Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:52:54 -0400 Subject: [PATCH 05/15] style: fix prettier formatting in storage test and context-offloader plugin --- strands-ts/src/storage/__tests__/in-memory-storage.test.ts | 1 - strands-ts/src/vended-plugins/context-offloader/plugin.ts | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/strands-ts/src/storage/__tests__/in-memory-storage.test.ts b/strands-ts/src/storage/__tests__/in-memory-storage.test.ts index b9c924fdb5..bbb034ffad 100644 --- a/strands-ts/src/storage/__tests__/in-memory-storage.test.ts +++ b/strands-ts/src/storage/__tests__/in-memory-storage.test.ts @@ -105,7 +105,6 @@ describe('InMemoryStorage', () => { }) }) - describe('key normalization', () => { it('normalizes slashes so equivalent keys resolve to the same entry', async () => { await storage.put('/a//b/', new Uint8Array([1])) diff --git a/strands-ts/src/vended-plugins/context-offloader/plugin.ts b/strands-ts/src/vended-plugins/context-offloader/plugin.ts index 5e7b0d1dbd..3cd45f6afb 100644 --- a/strands-ts/src/vended-plugins/context-offloader/plugin.ts +++ b/strands-ts/src/vended-plugins/context-offloader/plugin.ts @@ -230,9 +230,8 @@ export class ContextOffloader implements Plugin { if (previewTokens < 0) throw new Error('previewTokens must be non-negative') if (previewTokens >= maxResultTokens) throw new Error('previewTokens must be less than maxResultTokens') - const evictAfterCycles = config.evictAfterCycles === undefined - ? ContextOffloader._DEFAULT_EVICT_AFTER_CYCLES - : config.evictAfterCycles + const evictAfterCycles = + config.evictAfterCycles === undefined ? ContextOffloader._DEFAULT_EVICT_AFTER_CYCLES : config.evictAfterCycles if (evictAfterCycles !== null && (!Number.isInteger(evictAfterCycles) || evictAfterCycles < 1)) { throw new Error('evictAfterCycles must be a positive integer') } From 12cbb24bbed1e6271cf31c73a0f953a9731c5b05 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:40:43 -0400 Subject: [PATCH 06/15] fix: remove accidental prettier runtime dependency from lockfile prettier was incorrectly listed as a runtime dependency of strands-ts in the lockfile and its dev flag was missing. --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index ac226f933a..a4c31184ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5580,6 +5580,7 @@ "version": "3.9.4", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "dev": true, "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -6540,7 +6541,6 @@ "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1069.0", "@types/json-schema": "^7.0.15", - "prettier": "^3.9.4", "uuid": "^14.0.1", "yaml": "^2.8.3" }, From d0d5a77a1a66812c710b9bcda1b6bba57dcc0150 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:09:45 -0400 Subject: [PATCH 07/15] refactor(storage): address review feedback - Rename put/get to write/read across Storage interface and all implementations for neutrality on create-vs-upsert semantics - Make storage required on SessionManagerConfig and ContextOffloaderConfig (matches upstream main behavior) - Remove @internal from SessionStorage/SnapshotStorage (exported from barrel) - Add namespace() internal function; expose .namespace() on concrete classes - Remove _basePrefix from SnapshotStorageAdapter; caller namespaces via namespace(storage, 'session') - Rename lastAccess to storedCycle in eviction logic - Add ENOTDIR handling to isNotFoundError - Clean up tmp file on write failure in LocalFileStorage - Add test for tmp file cleanup on rename failure --- .../snapshot-storage-adapter.test.ts | 19 ++--- strands-ts/src/session/session-manager.ts | 23 ++---- .../src/session/snapshot-storage-adapter.ts | 21 +++--- strands-ts/src/session/storage.ts | 7 +- .../__tests__/in-memory-storage.test.ts | 64 ++++++++-------- .../__tests__/local-file-storage.test.node.ts | 60 +++++++++------ .../__tests__/namespaced-storage.test.ts | 75 +++++++++++++++++++ .../src/storage/__tests__/s3-storage.test.ts | 16 ++-- strands-ts/src/storage/in-memory-storage.ts | 19 ++++- strands-ts/src/storage/local-file-storage.ts | 43 +++++++---- strands-ts/src/storage/namespaced-storage.ts | 24 ++++++ strands-ts/src/storage/s3-storage.ts | 17 ++++- strands-ts/src/storage/storage.ts | 4 +- .../context-offloader/plugin.ts | 19 ++--- 14 files changed, 276 insertions(+), 135 deletions(-) create mode 100644 strands-ts/src/storage/__tests__/namespaced-storage.test.ts create mode 100644 strands-ts/src/storage/namespaced-storage.ts diff --git a/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts b/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts index b71c0afef4..ac110160f5 100644 --- a/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts +++ b/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, beforeEach } from 'vitest' import { SnapshotStorageAdapter } from '../snapshot-storage-adapter.js' import { InMemoryStorage } from '../../storage/in-memory-storage.js' +import { namespace } from '../../storage/namespaced-storage.js' import { SessionError } from '../../errors.js' import { createTestSnapshot, createTestManifest, createTestScope } from '../../__fixtures__/mock-storage-provider.js' import type { SnapshotLocation } from '../storage.js' @@ -27,7 +28,7 @@ describe('SnapshotStorageAdapter', () => { beforeEach(() => { backend = new InMemoryStorage() - adapter = new SnapshotStorageAdapter(backend) + adapter = new SnapshotStorageAdapter(namespace(backend, 'session')) }) describe('saveSnapshot', () => { @@ -240,9 +241,9 @@ describe('SnapshotStorageAdapter', () => { }) }) - describe('custom basePrefix', () => { - it('uses custom prefix for all keys', async () => { - const customAdapter = new SnapshotStorageAdapter(backend, 'custom/prefix') + describe('custom namespace', () => { + it('uses the namespace provided to the adapter', async () => { + const customAdapter = new SnapshotStorageAdapter(namespace(backend, 'custom/prefix')) const location = createLocation() await customAdapter.saveSnapshot({ @@ -262,10 +263,10 @@ describe('SnapshotStorageAdapter', () => { describe('error handling', () => { it('wraps storage write errors in SessionError', async () => { const failingBackend: InMemoryStorage = new InMemoryStorage() - failingBackend.put = async () => { + failingBackend.write = async () => { throw new Error('disk full') } - const failAdapter = new SnapshotStorageAdapter(failingBackend) + const failAdapter = new SnapshotStorageAdapter(namespace(failingBackend, 'session')) const location = createLocation() await expect( @@ -275,10 +276,10 @@ describe('SnapshotStorageAdapter', () => { it('wraps storage read errors in SessionError', async () => { const failingBackend: InMemoryStorage = new InMemoryStorage() - failingBackend.get = async () => { + failingBackend.read = async () => { throw new Error('network timeout') } - const failAdapter = new SnapshotStorageAdapter(failingBackend) + const failAdapter = new SnapshotStorageAdapter(namespace(failingBackend, 'session')) const location = createLocation() await expect(failAdapter.loadSnapshot({ location, snapshotId: uuidV7(1) })).rejects.toThrow(SessionError) @@ -287,7 +288,7 @@ describe('SnapshotStorageAdapter', () => { it('throws SessionError on corrupted JSON', async () => { const location = createLocation() const key = 'session/test-session/scopes/agent/test-agent/snapshots/snapshot_latest.json' - await backend.put(key, new TextEncoder().encode('not valid json{{{')) + await backend.write(key, new TextEncoder().encode('not valid json{{{')) await expect(adapter.loadSnapshot({ location })).rejects.toThrow(SessionError) await expect(adapter.loadSnapshot({ location })).rejects.toThrow(/Corrupted JSON/) diff --git a/strands-ts/src/session/session-manager.ts b/strands-ts/src/session/session-manager.ts index 00f3c936c2..8292a6bc0a 100644 --- a/strands-ts/src/session/session-manager.ts +++ b/strands-ts/src/session/session-manager.ts @@ -1,7 +1,7 @@ import type { SnapshotStorage, SnapshotLocation } from './storage.js' import type { Storage } from '../storage/storage.js' +import { namespace } from '../storage/namespaced-storage.js' import { SnapshotStorageAdapter } from './snapshot-storage-adapter.js' -import { SessionError } from '../errors.js' import { validateIdentifier } from './validation.js' import type { SnapshotTriggerCallback } from './types.js' import type { Plugin } from '../plugins/plugin.js' @@ -61,9 +61,9 @@ export interface SessionManagerConfig { * * Accepts either: * - A unified {@link Storage} instance (recommended) — wrapped internally with {@link SnapshotStorageAdapter} - * - A legacy `{ snapshot: SnapshotStorage }` object for backwards compatibility + * - A legacy `{ snapshot: SnapshotStorage }` object */ - storage?: Storage | { snapshot: SnapshotStorage } + storage: Storage | { snapshot: SnapshotStorage } /** Unique session identifier. Defaults to `'default-session'`. */ sessionId?: string /** When to save snapshot_latest. Default: `'invocation'` (after each agent invocation completes). See {@link SaveLatestStrategy} for details. */ @@ -98,7 +98,7 @@ export interface SessionManagerConfig { */ export class SessionManager implements Plugin, MultiAgentPlugin { private readonly _sessionId: string - private _storage: { snapshot: SnapshotStorage } | undefined + private readonly _storage: { snapshot: SnapshotStorage } private readonly _saveLatestOn: SaveLatestStrategy private readonly _snapshotTrigger?: SnapshotTriggerCallback | undefined private readonly _multiAgentSaveLatestOn: MultiAgentSaveLatestStrategy @@ -111,34 +111,25 @@ export class SessionManager implements Plugin, MultiAgentPlugin { return 'strands:session-manager' } - constructor(config: SessionManagerConfig = {}) { + constructor(config: SessionManagerConfig) { this._sessionId = validateIdentifier(config.sessionId ?? 'default-session') - this._storage = config.storage ? { snapshot: this._resolveSnapshotStorage(config.storage) } : undefined + this._storage = { snapshot: this._resolveSnapshotStorage(config.storage) } this._saveLatestOn = config.saveLatestOn ?? 'invocation' this._multiAgentSaveLatestOn = config.multiAgentSaveLatestOn ?? 'node' this._snapshotTrigger = config.snapshotTrigger } private get _snapshotStorage(): SnapshotStorage { - if (!this._storage) { - throw new SessionError('SessionManager storage not initialized') - } return this._storage.snapshot } private _resolveSnapshotStorage(storage: Storage | { snapshot: SnapshotStorage }): SnapshotStorage { if ('snapshot' in storage) return storage.snapshot - return new SnapshotStorageAdapter(storage) + return new SnapshotStorageAdapter(namespace(storage, 'session')) } /** Initializes the plugin by registering lifecycle hook callbacks. */ public initAgent(agent: LocalAgent): void { - if (!this._storage) { - throw new Error( - 'SessionManager requires a storage backend. ' + - 'Pass storage in the SessionManager config or set storage on the Agent config.' - ) - } agent.addHook(InitializedEvent, async (event) => { await this._onAgentInitialized(event) }) diff --git a/strands-ts/src/session/snapshot-storage-adapter.ts b/strands-ts/src/session/snapshot-storage-adapter.ts index c60e1910f7..117400d297 100644 --- a/strands-ts/src/session/snapshot-storage-adapter.ts +++ b/strands-ts/src/session/snapshot-storage-adapter.ts @@ -19,21 +19,22 @@ const SNAPSHOT_REGEX = /snapshot_([\w-]+)\.json$/ * Adapts a unified {@link Storage} instance into the {@link SnapshotStorage} interface * expected by the session manager. * - * Keys follow the same layout as the filesystem-based storage: - * `session//scopes///snapshots/...` + * Keys are written directly into the provided storage: + * `/scopes///snapshots/...` + * + * Callers control namespacing by passing a {@link NamespacedStorage} — e.g. + * `new NamespacedStorage(storage, 'session')` produces keys like + * `session//scopes/...`. * * @deprecated Remove in v2 when SnapshotStorage is dropped and SessionManager calls Storage directly. * @internal * @param storage - The unified Storage backend to delegate to - * @param basePrefix - Optional key prefix. Defaults to `'session'`. */ export class SnapshotStorageAdapter implements SnapshotStorage { private readonly _storage: Storage - private readonly _basePrefix: string - constructor(storage: Storage, basePrefix: string = 'session') { + constructor(storage: Storage) { this._storage = storage - this._basePrefix = basePrefix } /** @@ -105,7 +106,7 @@ export class SnapshotStorageAdapter implements SnapshotStorage { */ async deleteSession(params: { sessionId: string }): Promise { validateIdentifier(params.sessionId) - const prefix = `${this._basePrefix}/${params.sessionId}/` + const prefix = `${params.sessionId}/` const keys = await this._storage.list(prefix) const BATCH_SIZE = 100 for (let i = 0; i < keys.length; i += BATCH_SIZE) { @@ -143,7 +144,7 @@ export class SnapshotStorageAdapter implements SnapshotStorage { private _scopePrefix(location: SnapshotLocation): string { validateIdentifier(location.sessionId) validateIdentifier(location.scopeId) - return `${this._basePrefix}/${location.sessionId}/scopes/${location.scope}/${location.scopeId}/snapshots` + return `${location.sessionId}/scopes/${location.scope}/${location.scopeId}/snapshots` } private _latestKey(location: SnapshotLocation): string { @@ -165,7 +166,7 @@ export class SnapshotStorageAdapter implements SnapshotStorage { private async _writeJSON(key: string, data: unknown): Promise { try { const bytes = new TextEncoder().encode(JSON.stringify(data)) - await this._storage.put(key, bytes) + await this._storage.write(key, bytes) } catch (error: unknown) { throw new SessionError(`Failed to write '${key}' to storage`, { cause: error }) } @@ -173,7 +174,7 @@ export class SnapshotStorageAdapter implements SnapshotStorage { private async _readJSON(key: string): Promise { try { - const bytes = await this._storage.get(key) + const bytes = await this._storage.read(key) if (bytes === null) return null const text = new TextDecoder().decode(bytes) return JSON.parse(text) as T diff --git a/strands-ts/src/session/storage.ts b/strands-ts/src/session/storage.ts index b621b2022d..4f25d7aab7 100644 --- a/strands-ts/src/session/storage.ts +++ b/strands-ts/src/session/storage.ts @@ -16,8 +16,7 @@ export type SnapshotLocation = { * SessionStorage configuration for pluggable storage backends. * Allows users to configure snapshot and transcript storage independently. * - * @deprecated Remove in v2 when SessionManager accepts only unified `Storage`. - * @internal Prefer passing a unified `Storage` directly to `SessionManagerConfig.storage`. + * @deprecated Prefer passing a unified `Storage` directly to `SessionManagerConfig.storage`. */ export type SessionStorage = { snapshot: SnapshotStorage @@ -28,9 +27,7 @@ export type SessionStorage = { * Interface for snapshot persistence. * Implementations provide storage backends (S3, filesystem, etc.). * - * @deprecated Remove in v2 when SessionManager calls unified `Storage` directly. - * @internal This is an internal contract used by the session manager. Users should pass - * a unified `Storage` to `SessionManagerConfig.storage` instead of implementing this directly. + * @deprecated Prefer passing a unified `Storage` to `SessionManagerConfig.storage` instead of implementing this directly. * * File layout convention: * ``` diff --git a/strands-ts/src/storage/__tests__/in-memory-storage.test.ts b/strands-ts/src/storage/__tests__/in-memory-storage.test.ts index bbb034ffad..5196a3310d 100644 --- a/strands-ts/src/storage/__tests__/in-memory-storage.test.ts +++ b/strands-ts/src/storage/__tests__/in-memory-storage.test.ts @@ -9,50 +9,50 @@ describe('InMemoryStorage', () => { storage = new InMemoryStorage() }) - describe('put', () => { + describe('write', () => { it('stores data under the given key', async () => { const data = new TextEncoder().encode('hello') - await storage.put('test/key', data) - const result = await storage.get('test/key') + await storage.write('test/key', data) + const result = await storage.read('test/key') expect(result).toEqual(data) }) it('overwrites existing data', async () => { - await storage.put('key', new TextEncoder().encode('first')) - await storage.put('key', new TextEncoder().encode('second')) - const result = await storage.get('key') + await storage.write('key', new TextEncoder().encode('first')) + await storage.write('key', new TextEncoder().encode('second')) + const result = await storage.read('key') expect(new TextDecoder().decode(result!)).toBe('second') }) - it('copies bytes on put to prevent aliasing', async () => { + it('copies bytes on write to prevent aliasing', async () => { const data = new Uint8Array([1, 2, 3]) - await storage.put('key', data) + await storage.write('key', data) data[0] = 99 - const result = await storage.get('key') + const result = await storage.read('key') expect(result![0]).toBe(1) }) }) - describe('get', () => { + describe('read', () => { it('returns null for missing keys', async () => { - const result = await storage.get('nonexistent') + const result = await storage.read('nonexistent') expect(result).toBeNull() }) - it('copies bytes on get to prevent aliasing', async () => { - await storage.put('key', new Uint8Array([1, 2, 3])) - const first = await storage.get('key') + it('copies bytes on read to prevent aliasing', async () => { + await storage.write('key', new Uint8Array([1, 2, 3])) + const first = await storage.read('key') first![0] = 99 - const second = await storage.get('key') + const second = await storage.read('key') expect(second![0]).toBe(1) }) }) describe('delete', () => { it('removes an existing key', async () => { - await storage.put('key', new Uint8Array([1])) + await storage.write('key', new Uint8Array([1])) await storage.delete('key') - const result = await storage.get('key') + const result = await storage.read('key') expect(result).toBeNull() }) @@ -63,33 +63,33 @@ describe('InMemoryStorage', () => { describe('list', () => { it('returns keys matching a prefix', async () => { - await storage.put('sessions/a/data', new Uint8Array([1])) - await storage.put('sessions/b/data', new Uint8Array([2])) - await storage.put('memory/notes', new Uint8Array([3])) + await storage.write('sessions/a/data', new Uint8Array([1])) + await storage.write('sessions/b/data', new Uint8Array([2])) + await storage.write('memory/notes', new Uint8Array([3])) const keys = await storage.list('sessions/') expect(keys).toEqual(['sessions/a/data', 'sessions/b/data']) }) it('returns all keys when prefix is empty', async () => { - await storage.put('a', new Uint8Array([1])) - await storage.put('b', new Uint8Array([2])) + await storage.write('a', new Uint8Array([1])) + await storage.write('b', new Uint8Array([2])) const keys = await storage.list('') expect(keys).toEqual(['a', 'b']) }) it('returns keys sorted lexicographically', async () => { - await storage.put('c', new Uint8Array([3])) - await storage.put('a', new Uint8Array([1])) - await storage.put('b', new Uint8Array([2])) + await storage.write('c', new Uint8Array([3])) + await storage.write('a', new Uint8Array([1])) + await storage.write('b', new Uint8Array([2])) const keys = await storage.list('') expect(keys).toEqual(['a', 'b', 'c']) }) it('returns empty array when no keys match', async () => { - await storage.put('other/key', new Uint8Array([1])) + await storage.write('other/key', new Uint8Array([1])) const keys = await storage.list('sessions/') expect(keys).toEqual([]) }) @@ -97,8 +97,8 @@ describe('InMemoryStorage', () => { describe('clear', () => { it('removes all entries', async () => { - await storage.put('a', new Uint8Array([1])) - await storage.put('b', new Uint8Array([2])) + await storage.write('a', new Uint8Array([1])) + await storage.write('b', new Uint8Array([2])) storage.clear() const keys = await storage.list('') expect(keys).toEqual([]) @@ -107,17 +107,17 @@ describe('InMemoryStorage', () => { describe('key normalization', () => { it('normalizes slashes so equivalent keys resolve to the same entry', async () => { - await storage.put('/a//b/', new Uint8Array([1])) - const result = await storage.get('a/b') + await storage.write('/a//b/', new Uint8Array([1])) + const result = await storage.read('a/b') expect(result).toEqual(new Uint8Array([1])) }) it('rejects empty keys', async () => { - await expect(storage.put('', new Uint8Array([1]))).rejects.toThrow(StorageError) + await expect(storage.write('', new Uint8Array([1]))).rejects.toThrow(StorageError) }) it('rejects keys with .. segments', async () => { - await expect(storage.put('a/../b', new Uint8Array([1]))).rejects.toThrow(StorageError) + await expect(storage.write('a/../b', new Uint8Array([1]))).rejects.toThrow(StorageError) }) it('rejects prefixes with .. segments', async () => { diff --git a/strands-ts/src/storage/__tests__/local-file-storage.test.node.ts b/strands-ts/src/storage/__tests__/local-file-storage.test.node.ts index 42502d51be..a9457fc875 100644 --- a/strands-ts/src/storage/__tests__/local-file-storage.test.node.ts +++ b/strands-ts/src/storage/__tests__/local-file-storage.test.node.ts @@ -18,45 +18,63 @@ describe('LocalFileStorage', () => { await rm(baseDir, { recursive: true, force: true }) }) - describe('put and get', () => { + describe('write and read', () => { it('round-trips bytes', async () => { const data = new TextEncoder().encode('hello world') - await storage.put('test/file.txt', data) - const result = await storage.get('test/file.txt') + await storage.write('test/file.txt', data) + const result = await storage.read('test/file.txt') expect(result).toEqual(data) }) it('creates nested directories', async () => { - await storage.put('deep/nested/path/file.bin', new Uint8Array([1, 2, 3])) + await storage.write('deep/nested/path/file.bin', new Uint8Array([1, 2, 3])) const info = await stat(join(baseDir, 'deep/nested/path/file.bin')) expect(info.isFile()).toBe(true) }) it('overwrites existing values', async () => { - await storage.put('key', new TextEncoder().encode('first')) - await storage.put('key', new TextEncoder().encode('second')) - const result = await storage.get('key') + await storage.write('key', new TextEncoder().encode('first')) + await storage.write('key', new TextEncoder().encode('second')) + const result = await storage.read('key') expect(new TextDecoder().decode(result!)).toBe('second') }) it('returns null for missing keys', async () => { - const result = await storage.get('nonexistent/key') + const result = await storage.read('nonexistent/key') expect(result).toBeNull() }) it('writes atomically via tmp file', async () => { - await storage.put('atomic/test', new Uint8Array([1])) + await storage.write('atomic/test', new Uint8Array([1])) const content = await readFile(join(baseDir, 'atomic/test')) expect(new Uint8Array(content)).toEqual(new Uint8Array([1])) await expect(stat(join(baseDir, 'atomic/test.tmp'))).rejects.toThrow() }) + + it('cleans up tmp file on rename failure', async () => { + const { mkdir, chmod, readdir } = await import('node:fs/promises') + const dir = join(baseDir, 'readonly') + await mkdir(dir, { recursive: true }) + const { writeFile } = await import('node:fs/promises') + await writeFile(join(dir, 'target'), 'original') + await chmod(dir, 0o555) + + try { + await expect(storage.write('readonly/target', new Uint8Array([1]))).rejects.toThrow() + await chmod(dir, 0o755) + const files = await readdir(dir) + expect(files.filter((f) => f.includes('.__strands_tmp'))).toHaveLength(0) + } finally { + await chmod(dir, 0o755) + } + }) }) describe('delete', () => { it('removes an existing key', async () => { - await storage.put('deleteme', new Uint8Array([1])) + await storage.write('deleteme', new Uint8Array([1])) await storage.delete('deleteme') - const result = await storage.get('deleteme') + const result = await storage.read('deleteme') expect(result).toBeNull() }) @@ -67,17 +85,17 @@ describe('LocalFileStorage', () => { describe('list', () => { it('lists keys under a prefix', async () => { - await storage.put('sessions/a/data.json', new Uint8Array([1])) - await storage.put('sessions/b/data.json', new Uint8Array([2])) - await storage.put('memory/notes.json', new Uint8Array([3])) + await storage.write('sessions/a/data.json', new Uint8Array([1])) + await storage.write('sessions/b/data.json', new Uint8Array([2])) + await storage.write('memory/notes.json', new Uint8Array([3])) const keys = await storage.list('sessions/') expect(keys).toEqual(['sessions/a/data.json', 'sessions/b/data.json']) }) it('returns all keys for empty prefix', async () => { - await storage.put('a', new Uint8Array([1])) - await storage.put('b', new Uint8Array([2])) + await storage.write('a', new Uint8Array([1])) + await storage.write('b', new Uint8Array([2])) const keys = await storage.list('') expect(keys).toEqual(['a', 'b']) @@ -90,7 +108,7 @@ describe('LocalFileStorage', () => { }) it('excludes scratch files', async () => { - await storage.put('real', new Uint8Array([1])) + await storage.write('real', new Uint8Array([1])) const { writeFile, mkdir } = await import('node:fs/promises') await mkdir(baseDir, { recursive: true }) await writeFile(join(baseDir, 'leftover.__strands_tmp'), 'garbage') @@ -100,15 +118,15 @@ describe('LocalFileStorage', () => { }) it('does not exclude user .tmp files', async () => { - await storage.put('notes.tmp', new Uint8Array([1])) + await storage.write('notes.tmp', new Uint8Array([1])) const keys = await storage.list('') expect(keys).toContain('notes.tmp') }) it('returns keys sorted lexicographically', async () => { - await storage.put('c', new Uint8Array([3])) - await storage.put('a', new Uint8Array([1])) - await storage.put('b', new Uint8Array([2])) + await storage.write('c', new Uint8Array([3])) + await storage.write('a', new Uint8Array([1])) + await storage.write('b', new Uint8Array([2])) const keys = await storage.list('') expect(keys).toEqual(['a', 'b', 'c']) diff --git a/strands-ts/src/storage/__tests__/namespaced-storage.test.ts b/strands-ts/src/storage/__tests__/namespaced-storage.test.ts new file mode 100644 index 0000000000..c89586346d --- /dev/null +++ b/strands-ts/src/storage/__tests__/namespaced-storage.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { InMemoryStorage } from '../in-memory-storage.js' +import { namespace } from '../namespaced-storage.js' +import type { Storage } from '../storage.js' + +describe('namespace', () => { + let backend: InMemoryStorage + let namespaced: Storage + + beforeEach(() => { + backend = new InMemoryStorage() + namespaced = namespace(backend, 'prefix') + }) + + it('prepends namespace to write keys', async () => { + await namespaced.write('key', new Uint8Array([1, 2, 3])) + + const result = await backend.read('prefix/key') + expect(result).toEqual(new Uint8Array([1, 2, 3])) + }) + + it('prepends namespace to read keys', async () => { + await backend.write('prefix/key', new Uint8Array([4, 5])) + + const result = await namespaced.read('key') + expect(result).toEqual(new Uint8Array([4, 5])) + }) + + it('returns null for missing keys', async () => { + const result = await namespaced.read('nonexistent') + expect(result).toBeNull() + }) + + it('prepends namespace to delete keys', async () => { + await backend.write('prefix/key', new Uint8Array([1])) + + await namespaced.delete('key') + + expect(await backend.read('prefix/key')).toBeNull() + }) + + it('lists keys with namespace stripped', async () => { + await backend.write('prefix/a', new Uint8Array([1])) + await backend.write('prefix/b', new Uint8Array([2])) + await backend.write('other/c', new Uint8Array([3])) + + const keys = await namespaced.list('') + expect(keys).toEqual(['a', 'b']) + }) + + it('lists keys with sub-prefix', async () => { + await backend.write('prefix/session/abc', new Uint8Array([1])) + await backend.write('prefix/session/def', new Uint8Array([2])) + await backend.write('prefix/offloader/xyz', new Uint8Array([3])) + + const keys = await namespaced.list('session/') + expect(keys).toEqual(['session/abc', 'session/def']) + }) + + it('composes nested namespaces', async () => { + const nested = namespace(namespace(backend, 'prefix'), 'sub') + await nested.write('key', new Uint8Array([9])) + + const result = await backend.read('prefix/sub/key') + expect(result).toEqual(new Uint8Array([9])) + }) + + it('handles empty namespace as no-op prefix', async () => { + const empty = namespace(backend, '') + await empty.write('key', new Uint8Array([7])) + + const result = await backend.read('key') + expect(result).toEqual(new Uint8Array([7])) + }) +}) diff --git a/strands-ts/src/storage/__tests__/s3-storage.test.ts b/strands-ts/src/storage/__tests__/s3-storage.test.ts index 830e80f124..e832611b45 100644 --- a/strands-ts/src/storage/__tests__/s3-storage.test.ts +++ b/strands-ts/src/storage/__tests__/s3-storage.test.ts @@ -36,13 +36,13 @@ describe('S3Storage', () => { }) }) - describe('put', () => { + describe('write', () => { it('sends a PutObjectCommand with the correct params', async () => { mockSend.mockResolvedValue({}) const storage = new S3Storage('my-bucket', { prefix: 'agents/' }) const data = new TextEncoder().encode('payload') - await storage.put('sessions/abc/data.json', data) + await storage.write('sessions/abc/data.json', data) expect(mockPutObjectCommand).toHaveBeenCalledWith({ Bucket: 'my-bucket', @@ -56,17 +56,17 @@ describe('S3Storage', () => { mockSend.mockRejectedValue(new Error('AccessDenied')) const storage = new S3Storage('my-bucket') - await expect(storage.put('key', new Uint8Array([1]))).rejects.toThrow(StorageError) + await expect(storage.write('key', new Uint8Array([1]))).rejects.toThrow(StorageError) }) }) - describe('get', () => { + describe('read', () => { it('returns bytes when the object exists', async () => { const bytes = new Uint8Array([1, 2, 3]) mockSend.mockResolvedValue({ Body: { transformToByteArray: () => Promise.resolve(bytes) } }) const storage = new S3Storage('my-bucket') - const result = await storage.get('some/key') + const result = await storage.read('some/key') expect(result).toEqual(bytes) }) @@ -76,7 +76,7 @@ describe('S3Storage', () => { mockSend.mockRejectedValue(error) const storage = new S3Storage('my-bucket') - const result = await storage.get('missing') + const result = await storage.read('missing') expect(result).toBeNull() }) @@ -86,7 +86,7 @@ describe('S3Storage', () => { mockSend.mockRejectedValue(error) const storage = new S3Storage('my-bucket') - const result = await storage.get('missing') + const result = await storage.read('missing') expect(result).toBeNull() }) @@ -94,7 +94,7 @@ describe('S3Storage', () => { mockSend.mockRejectedValue(new Error('NetworkFailure')) const storage = new S3Storage('my-bucket') - await expect(storage.get('key')).rejects.toThrow(StorageError) + await expect(storage.read('key')).rejects.toThrow(StorageError) }) }) diff --git a/strands-ts/src/storage/in-memory-storage.ts b/strands-ts/src/storage/in-memory-storage.ts index e1ab49bb9a..b571e38a25 100644 --- a/strands-ts/src/storage/in-memory-storage.ts +++ b/strands-ts/src/storage/in-memory-storage.ts @@ -1,6 +1,7 @@ import type { Storage } from './storage.js' import { normalizeKey, normalizePrefix } from './normalize.js' +import { namespace } from './namespaced-storage.js' /** * In-memory {@link Storage} backend backed by a `Map`. @@ -18,8 +19,8 @@ import { normalizeKey, normalizePrefix } from './normalize.js' * @example * ```typescript * const storage = new InMemoryStorage() - * await storage.put('memory/notes.json', new TextEncoder().encode('[]')) - * const bytes = await storage.get('memory/notes.json') + * await storage.write('memory/notes.json', new TextEncoder().encode('[]')) + * const bytes = await storage.read('memory/notes.json') * ``` */ export class InMemoryStorage implements Storage { @@ -33,7 +34,7 @@ export class InMemoryStorage implements Storage { * @param data - Raw bytes to persist * @throws {@link StorageError} if the key is empty or contains `..` segments */ - async put(key: string, data: Uint8Array): Promise { + async write(key: string, data: Uint8Array): Promise { this._store.set(normalizeKey(key), data.slice()) } @@ -45,7 +46,7 @@ export class InMemoryStorage implements Storage { * @returns The stored bytes, or `null` if no value exists for `key` * @throws {@link StorageError} if the key is empty or contains `..` segments */ - async get(key: string): Promise { + async read(key: string): Promise { const value = this._store.get(normalizeKey(key)) if (value === undefined) return null return value.slice() @@ -77,6 +78,16 @@ export class InMemoryStorage implements Storage { return keys.sort() } + /** + * Returns a namespaced view of this storage with all keys prefixed. + * + * @param prefix - Prefix to prepend to all keys + * @returns A Storage view scoped to the given prefix + */ + namespace(prefix: string): Storage { + return namespace(this, prefix) + } + /** * Removes all stored entries. Useful for resetting state between tests. */ diff --git a/strands-ts/src/storage/local-file-storage.ts b/strands-ts/src/storage/local-file-storage.ts index a7f01f80ab..4c0cde9439 100644 --- a/strands-ts/src/storage/local-file-storage.ts +++ b/strands-ts/src/storage/local-file-storage.ts @@ -3,15 +3,17 @@ import type { Storage } from './storage.js' import { StorageError } from '../errors.js' import { normalizeKey, normalizePrefix } from './normalize.js' +import { namespace } from './namespaced-storage.js' /** - * Returns true if the error represents a missing file or directory (ENOENT). + * Returns true if the error represents a missing or non-directory path (ENOENT or ENOTDIR). * * @param error - The caught error to inspect - * @returns Whether the error is a filesystem ENOENT error + * @returns Whether the error is a filesystem not-found error */ -function isFileNotFoundError(error: unknown): boolean { - return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' +function isNotFoundError(error: unknown): boolean { + if (error === null || typeof error !== 'object' || !('code' in error)) return false + return error.code === 'ENOENT' || error.code === 'ENOTDIR' } /** @@ -29,7 +31,7 @@ function isFileNotFoundError(error: unknown): boolean { * import { LocalFileStorage } from '@strands-agents/sdk/storage' * * const storage = new LocalFileStorage('./.strands/') - * await storage.put('sessions/abc/snapshot.json', bytes) + * await storage.write('sessions/abc/snapshot.json', bytes) * ``` */ export class LocalFileStorage implements Storage { @@ -65,7 +67,7 @@ export class LocalFileStorage implements Storage { * @param data - Raw bytes to persist * @throws {@link StorageError} if the key is invalid or the write fails */ - async put(key: string, data: Uint8Array): Promise { + async write(key: string, data: Uint8Array): Promise { const normalized = normalizeKey(key) const path = this._pathFor(normalized) if (this._sandbox) { @@ -76,15 +78,20 @@ export class LocalFileStorage implements Storage { } return } + let tmpPath: string | undefined try { const { mkdir, writeFile, rename } = await import('node:fs/promises') const { dirname } = await import('node:path') await mkdir(dirname(path), { recursive: true }) const { randomUUID } = await import('node:crypto') - const tmpPath = `${path}.__strands_tmp_${randomUUID()}` + tmpPath = `${path}.__strands_tmp_${randomUUID()}` await writeFile(tmpPath, data) await rename(tmpPath, path) } catch (error: unknown) { + if (tmpPath) { + const { rm } = await import('node:fs/promises') + await rm(tmpPath, { force: true }).catch(() => {}) + } throw new StorageError(`Failed to write '${normalized}' to local storage`, { cause: error }) } } @@ -96,14 +103,14 @@ export class LocalFileStorage implements Storage { * @returns The stored bytes, or `null` if no value exists for `key` * @throws {@link StorageError} if the key is invalid or the read fails */ - async get(key: string): Promise { + async read(key: string): Promise { const normalized = normalizeKey(key) const path = this._pathFor(normalized) if (this._sandbox) { try { return await this._sandbox.readFile(path) } catch (error: unknown) { - if (isFileNotFoundError(error)) return null + if (isNotFoundError(error)) return null throw new StorageError(`Failed to read '${normalized}' from sandbox storage`, { cause: error }) } } @@ -112,7 +119,7 @@ export class LocalFileStorage implements Storage { const content = await readFile(path) return new Uint8Array(content) } catch (error: unknown) { - if (isFileNotFoundError(error)) return null + if (isNotFoundError(error)) return null throw new StorageError(`Failed to read '${normalized}' from local storage`, { cause: error }) } } @@ -130,7 +137,7 @@ export class LocalFileStorage implements Storage { try { await this._sandbox.removeFile(path) } catch (error: unknown) { - if (!isFileNotFoundError(error)) { + if (!isNotFoundError(error)) { throw new StorageError(`Failed to delete '${normalized}' from sandbox storage`, { cause: error }) } } @@ -177,7 +184,7 @@ export class LocalFileStorage implements Storage { try { entries = await readdir(walkDir, { withFileTypes: true }) } catch (error: unknown) { - if (isFileNotFoundError(error)) return [] + if (isNotFoundError(error)) return [] throw new StorageError(`Failed to list local storage under '${walkPrefix}'`, { cause: error }) } const found: string[] = [] @@ -204,7 +211,7 @@ export class LocalFileStorage implements Storage { try { entries = await sandbox.listFiles(walkDir) } catch (error: unknown) { - if (isFileNotFoundError(error)) return [] + if (isNotFoundError(error)) return [] throw new StorageError(`Failed to list sandbox storage under '${walkPrefix}'`, { cause: error }) } const found: string[] = [] @@ -222,4 +229,14 @@ export class LocalFileStorage implements Storage { return walk(dir, keyPrefix) } + + /** + * Returns a namespaced view of this storage with all keys prefixed. + * + * @param prefix - Prefix to prepend to all keys + * @returns A Storage view scoped to the given prefix + */ + namespace(prefix: string): Storage { + return namespace(this, prefix) + } } diff --git a/strands-ts/src/storage/namespaced-storage.ts b/strands-ts/src/storage/namespaced-storage.ts new file mode 100644 index 0000000000..ed9e5ddd46 --- /dev/null +++ b/strands-ts/src/storage/namespaced-storage.ts @@ -0,0 +1,24 @@ +import type { Storage } from './storage.js' +import { normalizePrefix } from './normalize.js' + +/** + * Returns a {@link Storage} view with all keys prefixed by `prefix`. + * + * Composable — calling `namespace()` on the result nests prefixes. + * + * @internal + * @param storage - The underlying storage to delegate to + * @param prefix - Prefix to prepend to all keys + * @returns A namespaced Storage view + */ +export function namespace(storage: Storage, prefix: string): Storage & { namespace(prefix: string): Storage } { + const normalized = normalizePrefix(prefix) + const p = normalized ? `${normalized}/` : '' + return { + write: (key, data) => storage.write(`${p}${key}`, data), + read: (key) => storage.read(`${p}${key}`), + delete: (key) => storage.delete(`${p}${key}`), + list: (query) => storage.list(`${p}${query}`).then((keys) => keys.map((key) => key.slice(p.length))), + namespace: (sub) => namespace(storage, `${p}${sub}`), + } +} diff --git a/strands-ts/src/storage/s3-storage.ts b/strands-ts/src/storage/s3-storage.ts index 47f32b701c..dca1b703d7 100644 --- a/strands-ts/src/storage/s3-storage.ts +++ b/strands-ts/src/storage/s3-storage.ts @@ -2,6 +2,7 @@ import type { Storage } from './storage.js' import { StorageError } from '../errors.js' import { normalizeKey, normalizePrefix } from './normalize.js' +import { namespace } from './namespaced-storage.js' /** Configuration for {@link S3Storage}. */ export interface S3StorageConfig { @@ -27,7 +28,7 @@ const S3_PAGE_SIZE = 1000 * import { S3Storage } from '@strands-agents/sdk/storage' * * const storage = new S3Storage('my-bucket', { prefix: 'agents/' }) - * await storage.put('sessions/abc/snapshot.json', bytes) + * await storage.write('sessions/abc/snapshot.json', bytes) * ``` */ export class S3Storage implements Storage { @@ -58,7 +59,7 @@ export class S3Storage implements Storage { * @param data - Raw bytes to persist * @throws {@link StorageError} if the key is invalid or the upload fails */ - async put(key: string, data: Uint8Array): Promise { + async write(key: string, data: Uint8Array): Promise { const normalized = normalizeKey(key) const client = await this._getClient() const { PutObjectCommand } = await import('@aws-sdk/client-s3') @@ -76,7 +77,7 @@ export class S3Storage implements Storage { * @returns The stored bytes, or `null` if no value exists for `key` * @throws {@link StorageError} if the key is invalid or the download fails */ - async get(key: string): Promise { + async read(key: string): Promise { const normalized = normalizeKey(key) const client = await this._getClient() const { GetObjectCommand } = await import('@aws-sdk/client-s3') @@ -154,6 +155,16 @@ export class S3Storage implements Storage { return this._client } + /** + * Returns a namespaced view of this storage with all keys prefixed. + * + * @param prefix - Prefix to prepend to all keys + * @returns A Storage view scoped to the given prefix + */ + namespace(prefix: string): Storage { + return namespace(this, prefix) + } + private _objectKey(key: string): string { return `${this._prefix}${key}` } diff --git a/strands-ts/src/storage/storage.ts b/strands-ts/src/storage/storage.ts index ed30c4043b..3f9d6b0d0b 100644 --- a/strands-ts/src/storage/storage.ts +++ b/strands-ts/src/storage/storage.ts @@ -21,7 +21,7 @@ export interface Storage { * @param data - Raw bytes to persist * @throws {@link StorageError} if the write fails */ - put(key: string, data: Uint8Array): Promise + write(key: string, data: Uint8Array): Promise /** * Retrieves the bytes previously stored under `key`. @@ -30,7 +30,7 @@ export interface Storage { * @returns The stored bytes, or `null` if no value exists for `key` * @throws {@link StorageError} if the read fails for a reason other than a missing key */ - get(key: string): Promise + read(key: string): Promise /** * Deletes the value stored under `key`. A no-op if the key does not exist. diff --git a/strands-ts/src/vended-plugins/context-offloader/plugin.ts b/strands-ts/src/vended-plugins/context-offloader/plugin.ts index 3cd45f6afb..32d322da07 100644 --- a/strands-ts/src/vended-plugins/context-offloader/plugin.ts +++ b/strands-ts/src/vended-plugins/context-offloader/plugin.ts @@ -45,7 +45,7 @@ async function storeContent( ): Promise { if (isOffloaderStorage(storage)) return storage.store(key, content, contentType) const ct = contentType ?? 'application/octet-stream' - await storage.put(key, frameContent(content, ct)) + await storage.write(key, frameContent(content, ct)) return key } @@ -54,7 +54,7 @@ async function retrieveContent( reference: string ): Promise<{ content: Uint8Array; contentType: string }> { if (isOffloaderStorage(storage)) return storage.retrieve(reference) - const data = await storage.get(reference) + const data = await storage.read(reference) if (data === null) throw new Error(`Reference not found: ${reference}`) return unframeContent(data) } @@ -157,10 +157,8 @@ export interface ContextOffloaderConfig { * occupies exactly one key (content-type is framed into the stored bytes). * - A legacy offloader `Storage` (deprecated, from this module) — manages its own turn-based * eviction internally via its `evictAfterTurns` constructor parameter. - * - * Required — must be provided in the plugin constructor. */ - storage?: Storage | OffloaderStorage + storage: Storage | OffloaderStorage /** Token threshold above which tool results are offloaded. Defaults to 2,500. */ maxResultTokens?: number /** Number of tokens to keep as an inline preview. Defaults to 1,000. */ @@ -213,7 +211,7 @@ export class ContextOffloader implements Plugin { private static readonly _DEFAULT_EVICT_AFTER_CYCLES = 20 - private readonly _storage: Storage | OffloaderStorage | undefined + private readonly _storage: Storage | OffloaderStorage private readonly _maxResultTokens: number private readonly _previewTokens: number private readonly _includeRetrievalTool: boolean @@ -222,7 +220,7 @@ export class ContextOffloader implements Plugin { private readonly _keyStoredAt = new Map() private _retrievalTool: Tool | undefined - constructor(config: ContextOffloaderConfig = {}) { + constructor(config: ContextOffloaderConfig) { const maxResultTokens = config.maxResultTokens ?? DEFAULT_MAX_RESULT_TOKENS const previewTokens = config.previewTokens ?? DEFAULT_PREVIEW_TOKENS @@ -244,9 +242,6 @@ export class ContextOffloader implements Plugin { } initAgent(agent: LocalAgent): void { - if (!this._storage) { - throw new Error('ContextOffloader requires a storage backend. Pass storage in the plugin config.') - } if (this._storage instanceof LegacyInMemoryStorage) { this._storage._bind(agent) } @@ -266,8 +261,8 @@ export class ContextOffloader implements Plugin { private _evict(currentCycle: number): void { const threshold = currentCycle - this._evictAfterCycles! const toEvict: string[] = [] - for (const [key, lastAccess] of this._keyStoredAt) { - if (lastAccess < threshold) { + for (const [key, storedCycle] of this._keyStoredAt) { + if (storedCycle < threshold) { toEvict.push(key) } } From 3cfd665bf90d88779f4286e26f2584003d0e4336 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:29:43 -0400 Subject: [PATCH 08/15] refactor(storage): inline namespace and normalize into storage.ts Consolidates the storage module by moving `namespace()` and `normalizeKey()`/`normalizePrefix()` directly into `storage.ts`, eliminating two single-purpose files. Also flows `evictAfterCycles` to legacy offloader storage when the user hasn't explicitly set `evictAfterTurns`. --- .../snapshot-storage-adapter.test.ts | 2 +- strands-ts/src/session/session-manager.ts | 2 +- .../__tests__/namespaced-storage.test.ts | 2 +- strands-ts/src/storage/in-memory-storage.ts | 3 +- strands-ts/src/storage/local-file-storage.ts | 3 +- strands-ts/src/storage/namespaced-storage.ts | 24 -------- strands-ts/src/storage/normalize.ts | 36 ----------- strands-ts/src/storage/s3-storage.ts | 3 +- strands-ts/src/storage/storage.ts | 59 +++++++++++++++++++ .../context-offloader/plugin.ts | 18 +++--- .../context-offloader/storage.ts | 3 +- 11 files changed, 75 insertions(+), 80 deletions(-) delete mode 100644 strands-ts/src/storage/namespaced-storage.ts delete mode 100644 strands-ts/src/storage/normalize.ts diff --git a/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts b/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts index ac110160f5..cc99ab525f 100644 --- a/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts +++ b/strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, beforeEach } from 'vitest' import { SnapshotStorageAdapter } from '../snapshot-storage-adapter.js' import { InMemoryStorage } from '../../storage/in-memory-storage.js' -import { namespace } from '../../storage/namespaced-storage.js' +import { namespace } from '../../storage/storage.js' import { SessionError } from '../../errors.js' import { createTestSnapshot, createTestManifest, createTestScope } from '../../__fixtures__/mock-storage-provider.js' import type { SnapshotLocation } from '../storage.js' diff --git a/strands-ts/src/session/session-manager.ts b/strands-ts/src/session/session-manager.ts index 8292a6bc0a..bb668e24b1 100644 --- a/strands-ts/src/session/session-manager.ts +++ b/strands-ts/src/session/session-manager.ts @@ -1,6 +1,6 @@ import type { SnapshotStorage, SnapshotLocation } from './storage.js' import type { Storage } from '../storage/storage.js' -import { namespace } from '../storage/namespaced-storage.js' +import { namespace } from '../storage/storage.js' import { SnapshotStorageAdapter } from './snapshot-storage-adapter.js' import { validateIdentifier } from './validation.js' import type { SnapshotTriggerCallback } from './types.js' diff --git a/strands-ts/src/storage/__tests__/namespaced-storage.test.ts b/strands-ts/src/storage/__tests__/namespaced-storage.test.ts index c89586346d..d29508566f 100644 --- a/strands-ts/src/storage/__tests__/namespaced-storage.test.ts +++ b/strands-ts/src/storage/__tests__/namespaced-storage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, beforeEach } from 'vitest' import { InMemoryStorage } from '../in-memory-storage.js' -import { namespace } from '../namespaced-storage.js' +import { namespace } from '../storage.js' import type { Storage } from '../storage.js' describe('namespace', () => { diff --git a/strands-ts/src/storage/in-memory-storage.ts b/strands-ts/src/storage/in-memory-storage.ts index b571e38a25..172cbf1f1d 100644 --- a/strands-ts/src/storage/in-memory-storage.ts +++ b/strands-ts/src/storage/in-memory-storage.ts @@ -1,7 +1,6 @@ import type { Storage } from './storage.js' -import { normalizeKey, normalizePrefix } from './normalize.js' -import { namespace } from './namespaced-storage.js' +import { namespace, normalizeKey, normalizePrefix } from './storage.js' /** * In-memory {@link Storage} backend backed by a `Map`. diff --git a/strands-ts/src/storage/local-file-storage.ts b/strands-ts/src/storage/local-file-storage.ts index 4c0cde9439..264bea0e0f 100644 --- a/strands-ts/src/storage/local-file-storage.ts +++ b/strands-ts/src/storage/local-file-storage.ts @@ -2,8 +2,7 @@ import type { Sandbox } from '../sandbox/base.js' import type { Storage } from './storage.js' import { StorageError } from '../errors.js' -import { normalizeKey, normalizePrefix } from './normalize.js' -import { namespace } from './namespaced-storage.js' +import { namespace, normalizeKey, normalizePrefix } from './storage.js' /** * Returns true if the error represents a missing or non-directory path (ENOENT or ENOTDIR). diff --git a/strands-ts/src/storage/namespaced-storage.ts b/strands-ts/src/storage/namespaced-storage.ts deleted file mode 100644 index ed9e5ddd46..0000000000 --- a/strands-ts/src/storage/namespaced-storage.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Storage } from './storage.js' -import { normalizePrefix } from './normalize.js' - -/** - * Returns a {@link Storage} view with all keys prefixed by `prefix`. - * - * Composable — calling `namespace()` on the result nests prefixes. - * - * @internal - * @param storage - The underlying storage to delegate to - * @param prefix - Prefix to prepend to all keys - * @returns A namespaced Storage view - */ -export function namespace(storage: Storage, prefix: string): Storage & { namespace(prefix: string): Storage } { - const normalized = normalizePrefix(prefix) - const p = normalized ? `${normalized}/` : '' - return { - write: (key, data) => storage.write(`${p}${key}`, data), - read: (key) => storage.read(`${p}${key}`), - delete: (key) => storage.delete(`${p}${key}`), - list: (query) => storage.list(`${p}${query}`).then((keys) => keys.map((key) => key.slice(p.length))), - namespace: (sub) => namespace(storage, `${p}${sub}`), - } -} diff --git a/strands-ts/src/storage/normalize.ts b/strands-ts/src/storage/normalize.ts deleted file mode 100644 index df43cc5b1b..0000000000 --- a/strands-ts/src/storage/normalize.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { StorageError } from '../errors.js' - -/** - * Validates and normalizes a storage key: collapses runs of `/`, strips leading - * and trailing `/`, and rejects empty keys and any `..` segment. - * - * @param key - The raw key to normalize - * @returns The normalized key - * @throws {@link StorageError} if the key is empty or contains a `..` segment - */ -export function normalizeKey(key: string): string { - const normalized = key.replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '') - if (normalized.length === 0) { - throw new StorageError('Storage key must not be empty') - } - if (normalized.split('/').includes('..')) { - throw new StorageError(`Invalid storage key '${key}': '..' path segments are not allowed`) - } - return normalized -} - -/** - * Normalizes a list prefix: collapses slash runs, strips leading slashes. - * Unlike a key, an empty prefix is valid and matches everything. - * - * @param prefix - The raw prefix to normalize - * @returns The normalized prefix - * @throws {@link StorageError} if the prefix contains a `..` segment - */ -export function normalizePrefix(prefix: string): string { - const normalized = prefix.replace(/\/+/g, '/').replace(/^\/+/, '') - if (normalized.split('/').includes('..')) { - throw new StorageError(`Invalid storage prefix '${prefix}': '..' path segments are not allowed`) - } - return normalized -} diff --git a/strands-ts/src/storage/s3-storage.ts b/strands-ts/src/storage/s3-storage.ts index dca1b703d7..8ba4e8f045 100644 --- a/strands-ts/src/storage/s3-storage.ts +++ b/strands-ts/src/storage/s3-storage.ts @@ -1,8 +1,7 @@ import type { Storage } from './storage.js' import { StorageError } from '../errors.js' -import { normalizeKey, normalizePrefix } from './normalize.js' -import { namespace } from './namespaced-storage.js' +import { namespace, normalizeKey, normalizePrefix } from './storage.js' /** Configuration for {@link S3Storage}. */ export interface S3StorageConfig { diff --git a/strands-ts/src/storage/storage.ts b/strands-ts/src/storage/storage.ts index 3f9d6b0d0b..31d7202399 100644 --- a/strands-ts/src/storage/storage.ts +++ b/strands-ts/src/storage/storage.ts @@ -1,3 +1,40 @@ +import { StorageError } from '../errors.js' + +/** + * Validates and normalizes a storage key: collapses runs of `/`, strips leading + * and trailing `/`, and rejects empty keys and any `..` segment. + * + * @param key - The raw key to normalize + * @returns The normalized key + * @throws {@link StorageError} if the key is empty or contains a `..` segment + */ +export function normalizeKey(key: string): string { + const normalized = key.replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '') + if (normalized.length === 0) { + throw new StorageError('Storage key must not be empty') + } + if (normalized.split('/').includes('..')) { + throw new StorageError(`Invalid storage key '${key}': '..' path segments are not allowed`) + } + return normalized +} + +/** + * Normalizes a list prefix: collapses slash runs, strips leading slashes. + * Unlike a key, an empty prefix is valid and matches everything. + * + * @param prefix - The raw prefix to normalize + * @returns The normalized prefix + * @throws {@link StorageError} if the prefix contains a `..` segment + */ +export function normalizePrefix(prefix: string): string { + const normalized = prefix.replace(/\/+/g, '/').replace(/^\/+/, '') + if (normalized.split('/').includes('..')) { + throw new StorageError(`Invalid storage prefix '${prefix}': '..' path segments are not allowed`) + } + return normalized +} + /** * A backend for storing and retrieving raw bytes under string keys. * @@ -56,3 +93,25 @@ export interface Storage { */ list(query: ListQuery): Promise } + +/** + * Returns a {@link Storage} view with all keys prefixed by `prefix`. + * + * Composable — calling `namespace()` on the result nests prefixes. + * + * @internal + * @param storage - The underlying storage to delegate to + * @param prefix - Prefix to prepend to all keys + * @returns A namespaced Storage view + */ +export function namespace(storage: Storage, prefix: string): Storage & { namespace(prefix: string): Storage } { + const normalized = normalizePrefix(prefix) + const p = normalized ? `${normalized}/` : '' + return { + write: (key, data) => storage.write(`${p}${key}`, data), + read: (key) => storage.read(`${p}${key}`), + delete: (key) => storage.delete(`${p}${key}`), + list: (query) => storage.list(`${p}${query}`).then((keys) => keys.map((key) => key.slice(p.length))), + namespace: (sub) => namespace(storage, `${p}${sub}`), + } +} diff --git a/strands-ts/src/vended-plugins/context-offloader/plugin.ts b/strands-ts/src/vended-plugins/context-offloader/plugin.ts index 32d322da07..efc81b342a 100644 --- a/strands-ts/src/vended-plugins/context-offloader/plugin.ts +++ b/strands-ts/src/vended-plugins/context-offloader/plugin.ts @@ -169,7 +169,6 @@ export interface ContextOffloaderConfig { * Number of agent loop cycles before an offloaded entry is evicted. * Entries stored more than this many cycles ago are deleted. * Defaults to 20. Set to `null` to disable eviction. - * Only applies to unified `Storage` backends — legacy backends manage their own eviction. */ evictAfterCycles?: number | null } @@ -183,15 +182,8 @@ export interface ContextOffloaderConfig { * * ## Eviction behavior * - * How offloaded entries are evicted depends on the storage backend: - * - * - **Unified `Storage`** (from `@strands-agents/sdk/storage`): the plugin records the - * cycle count (from `agent.metrics.cycleCount`) when each key is stored. Entries stored - * more than `evictAfterCycles` cycles ago are deleted. Defaults to 20 cycles. - * - * - **Legacy offloader storage** (deprecated `InMemoryStorage` from this module): the storage - * manages its own turn-based eviction internally. Entries not accessed within - * `evictAfterTurns` model invocation cycles are automatically removed. + * Offloaded entries are evicted after `evictAfterCycles` agent loop cycles (default 20). + * This applies to both unified `Storage` backends and legacy offloader storage. * * @example * ```typescript @@ -244,6 +236,12 @@ export class ContextOffloader implements Plugin { initAgent(agent: LocalAgent): void { if (this._storage instanceof LegacyInMemoryStorage) { this._storage._bind(agent) + if ( + this._evictAfterCycles !== null && + this._storage._evictAfterTurns === LegacyInMemoryStorage.DEFAULT_EVICT_AFTER_TURNS + ) { + this._storage._evictAfterTurns = this._evictAfterCycles + } } this._storageForAgent(agent) agent.addHook(AfterToolCallEvent, (event) => this._handleToolResult(event)) diff --git a/strands-ts/src/vended-plugins/context-offloader/storage.ts b/strands-ts/src/vended-plugins/context-offloader/storage.ts index 2d901effe1..628e84247f 100644 --- a/strands-ts/src/vended-plugins/context-offloader/storage.ts +++ b/strands-ts/src/vended-plugins/context-offloader/storage.ts @@ -85,7 +85,8 @@ export class InMemoryStorage implements Storage { private _store = new Map() private _counter = 0 private _currentCycle = 0 - private readonly _evictAfterTurns: number | null + /** @internal */ + _evictAfterTurns: number | null private _boundAgent: WeakRef | null = null static readonly DEFAULT_EVICT_AFTER_TURNS = 20 From c96f37ffe9450a71702b82f1b59cfe6513054f1e Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:56:36 -0400 Subject: [PATCH 09/15] refactor(offloader): pass evictAfterCycles through _bind() instead of mutating field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps _evictAfterTurns private — the legacy storage class owns the decision about whether to accept the override. --- .../vended-plugins/context-offloader/plugin.ts | 8 +------- .../vended-plugins/context-offloader/storage.ts | 15 +++++++++++---- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/strands-ts/src/vended-plugins/context-offloader/plugin.ts b/strands-ts/src/vended-plugins/context-offloader/plugin.ts index efc81b342a..75ced27c54 100644 --- a/strands-ts/src/vended-plugins/context-offloader/plugin.ts +++ b/strands-ts/src/vended-plugins/context-offloader/plugin.ts @@ -235,13 +235,7 @@ export class ContextOffloader implements Plugin { initAgent(agent: LocalAgent): void { if (this._storage instanceof LegacyInMemoryStorage) { - this._storage._bind(agent) - if ( - this._evictAfterCycles !== null && - this._storage._evictAfterTurns === LegacyInMemoryStorage.DEFAULT_EVICT_AFTER_TURNS - ) { - this._storage._evictAfterTurns = this._evictAfterCycles - } + this._storage._bind(agent, this._evictAfterCycles) } this._storageForAgent(agent) agent.addHook(AfterToolCallEvent, (event) => this._handleToolResult(event)) diff --git a/strands-ts/src/vended-plugins/context-offloader/storage.ts b/strands-ts/src/vended-plugins/context-offloader/storage.ts index 628e84247f..6276780524 100644 --- a/strands-ts/src/vended-plugins/context-offloader/storage.ts +++ b/strands-ts/src/vended-plugins/context-offloader/storage.ts @@ -85,8 +85,7 @@ export class InMemoryStorage implements Storage { private _store = new Map() private _counter = 0 private _currentCycle = 0 - /** @internal */ - _evictAfterTurns: number | null + private _evictAfterTurns: number | null private _boundAgent: WeakRef | null = null static readonly DEFAULT_EVICT_AFTER_TURNS = 20 @@ -117,10 +116,11 @@ export class InMemoryStorage implements Storage { } /** - * Claim this storage for a single agent. Throws if already bound to a different agent. + * Claim this storage for a single agent. Optionally override the eviction window + * when the user hasn't explicitly set one at construction time. * @internal */ - _bind(agent: object): void { + _bind(agent: object, evictAfterCycles?: number | null): void { if (this._boundAgent === null) { this._boundAgent = new WeakRef(agent) } else if (this._boundAgent.deref() !== agent) { @@ -129,6 +129,13 @@ export class InMemoryStorage implements Storage { 'Use a separate InMemoryStorage instance per agent.' ) } + if ( + evictAfterCycles !== undefined && + evictAfterCycles !== null && + this._evictAfterTurns === InMemoryStorage.DEFAULT_EVICT_AFTER_TURNS + ) { + this._evictAfterTurns = evictAfterCycles + } } /** From 9684436957cd640977a3dc35bf784cd2050b7e97 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:21:09 -0400 Subject: [PATCH 10/15] refactor(storage): remove public .namespace() methods from concrete classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps namespace() as an internal utility only. Users don't need it today — subsystem isolation is handled internally by the SDK. Can be promoted to a public API later once the right shape is decided. --- strands-ts/src/storage/in-memory-storage.ts | 12 +----------- strands-ts/src/storage/local-file-storage.ts | 12 +----------- strands-ts/src/storage/s3-storage.ts | 12 +----------- 3 files changed, 3 insertions(+), 33 deletions(-) diff --git a/strands-ts/src/storage/in-memory-storage.ts b/strands-ts/src/storage/in-memory-storage.ts index 172cbf1f1d..d1d4071a8b 100644 --- a/strands-ts/src/storage/in-memory-storage.ts +++ b/strands-ts/src/storage/in-memory-storage.ts @@ -1,6 +1,6 @@ import type { Storage } from './storage.js' -import { namespace, normalizeKey, normalizePrefix } from './storage.js' +import { normalizeKey, normalizePrefix } from './storage.js' /** * In-memory {@link Storage} backend backed by a `Map`. @@ -77,16 +77,6 @@ export class InMemoryStorage implements Storage { return keys.sort() } - /** - * Returns a namespaced view of this storage with all keys prefixed. - * - * @param prefix - Prefix to prepend to all keys - * @returns A Storage view scoped to the given prefix - */ - namespace(prefix: string): Storage { - return namespace(this, prefix) - } - /** * Removes all stored entries. Useful for resetting state between tests. */ diff --git a/strands-ts/src/storage/local-file-storage.ts b/strands-ts/src/storage/local-file-storage.ts index 264bea0e0f..ea9887b08a 100644 --- a/strands-ts/src/storage/local-file-storage.ts +++ b/strands-ts/src/storage/local-file-storage.ts @@ -2,7 +2,7 @@ import type { Sandbox } from '../sandbox/base.js' import type { Storage } from './storage.js' import { StorageError } from '../errors.js' -import { namespace, normalizeKey, normalizePrefix } from './storage.js' +import { normalizeKey, normalizePrefix } from './storage.js' /** * Returns true if the error represents a missing or non-directory path (ENOENT or ENOTDIR). @@ -228,14 +228,4 @@ export class LocalFileStorage implements Storage { return walk(dir, keyPrefix) } - - /** - * Returns a namespaced view of this storage with all keys prefixed. - * - * @param prefix - Prefix to prepend to all keys - * @returns A Storage view scoped to the given prefix - */ - namespace(prefix: string): Storage { - return namespace(this, prefix) - } } diff --git a/strands-ts/src/storage/s3-storage.ts b/strands-ts/src/storage/s3-storage.ts index 8ba4e8f045..2222c0b441 100644 --- a/strands-ts/src/storage/s3-storage.ts +++ b/strands-ts/src/storage/s3-storage.ts @@ -1,7 +1,7 @@ import type { Storage } from './storage.js' import { StorageError } from '../errors.js' -import { namespace, normalizeKey, normalizePrefix } from './storage.js' +import { normalizeKey, normalizePrefix } from './storage.js' /** Configuration for {@link S3Storage}. */ export interface S3StorageConfig { @@ -154,16 +154,6 @@ export class S3Storage implements Storage { return this._client } - /** - * Returns a namespaced view of this storage with all keys prefixed. - * - * @param prefix - Prefix to prepend to all keys - * @returns A Storage view scoped to the given prefix - */ - namespace(prefix: string): Storage { - return namespace(this, prefix) - } - private _objectKey(key: string): string { return `${this._prefix}${key}` } From 9406fac52af904ee669841b447fddbdebb4e87c3 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:03:07 -0400 Subject: [PATCH 11/15] fix(storage): skip chmod-based test on Windows Windows NTFS doesn't enforce Unix permission bits, so chmod(0o555) doesn't make the directory read-only and the rename succeeds instead of throwing. --- .../src/storage/__tests__/local-file-storage.test.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strands-ts/src/storage/__tests__/local-file-storage.test.node.ts b/strands-ts/src/storage/__tests__/local-file-storage.test.node.ts index a9457fc875..3fbe7d28fd 100644 --- a/strands-ts/src/storage/__tests__/local-file-storage.test.node.ts +++ b/strands-ts/src/storage/__tests__/local-file-storage.test.node.ts @@ -51,7 +51,7 @@ describe('LocalFileStorage', () => { await expect(stat(join(baseDir, 'atomic/test.tmp'))).rejects.toThrow() }) - it('cleans up tmp file on rename failure', async () => { + it.skipIf(process.platform === 'win32')('cleans up tmp file on rename failure', async () => { const { mkdir, chmod, readdir } = await import('node:fs/promises') const dir = join(baseDir, 'readonly') await mkdir(dir, { recursive: true }) From 1c215edd2b1b10036e53924eb21ea7261bb22141 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:05:19 -0400 Subject: [PATCH 12/15] refactor(storage): remove @deprecated from SnapshotStorageAdapter The adapter is the happy path for migrating to unified Storage, not something users should avoid. --- strands-ts/src/session/snapshot-storage-adapter.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/strands-ts/src/session/snapshot-storage-adapter.ts b/strands-ts/src/session/snapshot-storage-adapter.ts index 117400d297..cf850c22ce 100644 --- a/strands-ts/src/session/snapshot-storage-adapter.ts +++ b/strands-ts/src/session/snapshot-storage-adapter.ts @@ -22,11 +22,10 @@ const SNAPSHOT_REGEX = /snapshot_([\w-]+)\.json$/ * Keys are written directly into the provided storage: * `/scopes///snapshots/...` * - * Callers control namespacing by passing a {@link NamespacedStorage} — e.g. - * `new NamespacedStorage(storage, 'session')` produces keys like + * Callers control namespacing by passing a namespaced storage — e.g. + * `namespace(storage, 'session')` produces keys like * `session//scopes/...`. * - * @deprecated Remove in v2 when SnapshotStorage is dropped and SessionManager calls Storage directly. * @internal * @param storage - The unified Storage backend to delegate to */ From 5230716c39144701b7e4995621ce3ad639f1d098 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:40:56 -0400 Subject: [PATCH 13/15] refactor(storage): add optional .namespace() to Storage interface Shipped backends (InMemoryStorage, LocalFileStorage, S3Storage) implement .namespace() for user-facing key scoping. Custom backends may omit it. Also fixes evictAfterCycles: null not disabling eviction on legacy storage. --- strands-ts/src/storage/in-memory-storage.ts | 7 ++++++- strands-ts/src/storage/local-file-storage.ts | 7 ++++++- strands-ts/src/storage/s3-storage.ts | 7 ++++++- strands-ts/src/storage/storage.ts | 11 +++++++++++ .../src/vended-plugins/context-offloader/storage.ts | 6 +----- 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/strands-ts/src/storage/in-memory-storage.ts b/strands-ts/src/storage/in-memory-storage.ts index d1d4071a8b..a09c225c9b 100644 --- a/strands-ts/src/storage/in-memory-storage.ts +++ b/strands-ts/src/storage/in-memory-storage.ts @@ -1,6 +1,6 @@ import type { Storage } from './storage.js' -import { normalizeKey, normalizePrefix } from './storage.js' +import { namespace, normalizeKey, normalizePrefix } from './storage.js' /** * In-memory {@link Storage} backend backed by a `Map`. @@ -77,6 +77,11 @@ export class InMemoryStorage implements Storage { return keys.sort() } + /** Returns a prefixed view of this storage without mutating the original. */ + namespace(prefix: string): Storage { + return namespace(this, prefix) + } + /** * Removes all stored entries. Useful for resetting state between tests. */ diff --git a/strands-ts/src/storage/local-file-storage.ts b/strands-ts/src/storage/local-file-storage.ts index ea9887b08a..1ac4e5a3f8 100644 --- a/strands-ts/src/storage/local-file-storage.ts +++ b/strands-ts/src/storage/local-file-storage.ts @@ -2,7 +2,7 @@ import type { Sandbox } from '../sandbox/base.js' import type { Storage } from './storage.js' import { StorageError } from '../errors.js' -import { normalizeKey, normalizePrefix } from './storage.js' +import { namespace, normalizeKey, normalizePrefix } from './storage.js' /** * Returns true if the error represents a missing or non-directory path (ENOENT or ENOTDIR). @@ -228,4 +228,9 @@ export class LocalFileStorage implements Storage { return walk(dir, keyPrefix) } + + /** Returns a prefixed view of this storage without mutating the original. */ + namespace(prefix: string): Storage { + return namespace(this, prefix) + } } diff --git a/strands-ts/src/storage/s3-storage.ts b/strands-ts/src/storage/s3-storage.ts index 2222c0b441..08894e1189 100644 --- a/strands-ts/src/storage/s3-storage.ts +++ b/strands-ts/src/storage/s3-storage.ts @@ -1,7 +1,7 @@ import type { Storage } from './storage.js' import { StorageError } from '../errors.js' -import { normalizeKey, normalizePrefix } from './storage.js' +import { namespace, normalizeKey, normalizePrefix } from './storage.js' /** Configuration for {@link S3Storage}. */ export interface S3StorageConfig { @@ -154,6 +154,11 @@ export class S3Storage implements Storage { return this._client } + /** Returns a prefixed view of this storage without mutating the original. */ + namespace(prefix: string): Storage { + return namespace(this, prefix) + } + private _objectKey(key: string): string { return `${this._prefix}${key}` } diff --git a/strands-ts/src/storage/storage.ts b/strands-ts/src/storage/storage.ts index 31d7202399..fb137527d1 100644 --- a/strands-ts/src/storage/storage.ts +++ b/strands-ts/src/storage/storage.ts @@ -92,6 +92,17 @@ export interface Storage { * @throws {@link StorageError} if the listing fails */ list(query: ListQuery): Promise + + /** + * Returns a view of this storage with all keys prefixed by `prefix`. + * The original storage is not mutated. + * + * Optional — shipped backends implement this, custom backends may omit it. + * + * @param prefix - Prefix to prepend to all keys + * @returns A Storage view scoped to the given prefix + */ + namespace?(prefix: string): Storage } /** diff --git a/strands-ts/src/vended-plugins/context-offloader/storage.ts b/strands-ts/src/vended-plugins/context-offloader/storage.ts index 6276780524..5e250d3da9 100644 --- a/strands-ts/src/vended-plugins/context-offloader/storage.ts +++ b/strands-ts/src/vended-plugins/context-offloader/storage.ts @@ -129,11 +129,7 @@ export class InMemoryStorage implements Storage { 'Use a separate InMemoryStorage instance per agent.' ) } - if ( - evictAfterCycles !== undefined && - evictAfterCycles !== null && - this._evictAfterTurns === InMemoryStorage.DEFAULT_EVICT_AFTER_TURNS - ) { + if (evictAfterCycles !== undefined && this._evictAfterTurns === InMemoryStorage.DEFAULT_EVICT_AFTER_TURNS) { this._evictAfterTurns = evictAfterCycles } } From 823e36f7fc8be8ede37c97babd91168de9b65ac5 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:49:09 -0400 Subject: [PATCH 14/15] docs(storage): fix stale comments and simplify namespace return type Remove outdated evictAfterTurns doc, update snapshot-storage-adapter to reference .namespace() method, simplify namespace() return type now that the method is on the Storage interface. --- strands-ts/src/session/snapshot-storage-adapter.ts | 4 ++-- strands-ts/src/storage/storage.ts | 2 +- strands-ts/src/vended-plugins/context-offloader/plugin.ts | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/strands-ts/src/session/snapshot-storage-adapter.ts b/strands-ts/src/session/snapshot-storage-adapter.ts index cf850c22ce..2bad5b8c99 100644 --- a/strands-ts/src/session/snapshot-storage-adapter.ts +++ b/strands-ts/src/session/snapshot-storage-adapter.ts @@ -22,8 +22,8 @@ const SNAPSHOT_REGEX = /snapshot_([\w-]+)\.json$/ * Keys are written directly into the provided storage: * `/scopes///snapshots/...` * - * Callers control namespacing by passing a namespaced storage — e.g. - * `namespace(storage, 'session')` produces keys like + * Callers control namespacing by passing a scoped storage — e.g. + * `storage.namespace('session')` produces keys like * `session//scopes/...`. * * @internal diff --git a/strands-ts/src/storage/storage.ts b/strands-ts/src/storage/storage.ts index fb137527d1..381e23a55a 100644 --- a/strands-ts/src/storage/storage.ts +++ b/strands-ts/src/storage/storage.ts @@ -115,7 +115,7 @@ export interface Storage { * @param prefix - Prefix to prepend to all keys * @returns A namespaced Storage view */ -export function namespace(storage: Storage, prefix: string): Storage & { namespace(prefix: string): Storage } { +export function namespace(storage: Storage, prefix: string): Storage { const normalized = normalizePrefix(prefix) const p = normalized ? `${normalized}/` : '' return { diff --git a/strands-ts/src/vended-plugins/context-offloader/plugin.ts b/strands-ts/src/vended-plugins/context-offloader/plugin.ts index 75ced27c54..e816f9128b 100644 --- a/strands-ts/src/vended-plugins/context-offloader/plugin.ts +++ b/strands-ts/src/vended-plugins/context-offloader/plugin.ts @@ -155,8 +155,7 @@ export interface ContextOffloaderConfig { * Accepts either: * - A unified `Storage` (from `@strands-agents/sdk/storage`) — each offloaded content block * occupies exactly one key (content-type is framed into the stored bytes). - * - A legacy offloader `Storage` (deprecated, from this module) — manages its own turn-based - * eviction internally via its `evictAfterTurns` constructor parameter. + * - A legacy offloader `Storage` (deprecated, from this module). */ storage: Storage | OffloaderStorage /** Token threshold above which tool results are offloaded. Defaults to 2,500. */ From 6b16e9726d202dfff3da0d6239fb9376e4a87ba3 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:55:44 -0400 Subject: [PATCH 15/15] feat(storage): add NAMESPACED symbol for construct auto-prefix detection Constructs like SessionManager auto-namespace with a default prefix (e.g. 'session') only when the user hasn't already scoped the storage. The NAMESPACED symbol on views returned by namespace() enables this detection without adding to the public Storage interface. --- strands-ts/src/session/session-manager.ts | 5 +++-- .../src/storage/__tests__/namespaced-storage.test.ts | 10 +++++++++- strands-ts/src/storage/storage.ts | 12 +++++++++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/strands-ts/src/session/session-manager.ts b/strands-ts/src/session/session-manager.ts index bb668e24b1..f32cc6f33d 100644 --- a/strands-ts/src/session/session-manager.ts +++ b/strands-ts/src/session/session-manager.ts @@ -1,6 +1,6 @@ import type { SnapshotStorage, SnapshotLocation } from './storage.js' import type { Storage } from '../storage/storage.js' -import { namespace } from '../storage/storage.js' +import { NAMESPACED, namespace } from '../storage/storage.js' import { SnapshotStorageAdapter } from './snapshot-storage-adapter.js' import { validateIdentifier } from './validation.js' import type { SnapshotTriggerCallback } from './types.js' @@ -125,7 +125,8 @@ export class SessionManager implements Plugin, MultiAgentPlugin { private _resolveSnapshotStorage(storage: Storage | { snapshot: SnapshotStorage }): SnapshotStorage { if ('snapshot' in storage) return storage.snapshot - return new SnapshotStorageAdapter(namespace(storage, 'session')) + const scoped = NAMESPACED in storage ? storage : namespace(storage, 'session') + return new SnapshotStorageAdapter(scoped) } /** Initializes the plugin by registering lifecycle hook callbacks. */ diff --git a/strands-ts/src/storage/__tests__/namespaced-storage.test.ts b/strands-ts/src/storage/__tests__/namespaced-storage.test.ts index d29508566f..c12e1e9f6d 100644 --- a/strands-ts/src/storage/__tests__/namespaced-storage.test.ts +++ b/strands-ts/src/storage/__tests__/namespaced-storage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, beforeEach } from 'vitest' import { InMemoryStorage } from '../in-memory-storage.js' -import { namespace } from '../storage.js' +import { NAMESPACED, namespace } from '../storage.js' import type { Storage } from '../storage.js' describe('namespace', () => { @@ -72,4 +72,12 @@ describe('namespace', () => { const result = await backend.read('key') expect(result).toEqual(new Uint8Array([7])) }) + + it('sets NAMESPACED symbol on returned view', () => { + expect(NAMESPACED in namespaced).toBe(true) + }) + + it('does not have NAMESPACED symbol on raw storage', () => { + expect(NAMESPACED in backend).toBe(false) + }) }) diff --git a/strands-ts/src/storage/storage.ts b/strands-ts/src/storage/storage.ts index 381e23a55a..f0170b113e 100644 --- a/strands-ts/src/storage/storage.ts +++ b/strands-ts/src/storage/storage.ts @@ -1,5 +1,13 @@ import { StorageError } from '../errors.js' +/** + * Symbol present on namespaced storage views. Constructs use this to detect + * whether the caller already scoped the storage, skipping the default prefix. + * + * @internal + */ +export const NAMESPACED: unique symbol = Symbol.for('strands.storage.namespaced') + /** * Validates and normalizes a storage key: collapses runs of `/`, strips leading * and trailing `/`, and rejects empty keys and any `..` segment. @@ -118,11 +126,13 @@ export interface Storage { export function namespace(storage: Storage, prefix: string): Storage { const normalized = normalizePrefix(prefix) const p = normalized ? `${normalized}/` : '' - return { + const view: Storage & { [NAMESPACED]: true } = { write: (key, data) => storage.write(`${p}${key}`, data), read: (key) => storage.read(`${p}${key}`), delete: (key) => storage.delete(`${p}${key}`), list: (query) => storage.list(`${p}${query}`).then((keys) => keys.map((key) => key.slice(p.length))), namespace: (sub) => namespace(storage, `${p}${sub}`), + [NAMESPACED]: true, } + return view }