Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0dd1a75
feat(storage): add unified Storage interface and wire into all subsys…
lizradway Jul 7, 2026
0a1af62
style: fix agent.ts formatting for prettier 3.9
lizradway Jul 7, 2026
efcb70d
refactor(storage): simplify interface and move eviction to consumers
lizradway Jul 8, 2026
4378875
fix(context-offloader): address review feedback on eviction
lizradway Jul 8, 2026
c4c732a
style: fix prettier formatting in storage test and context-offloader …
lizradway Jul 8, 2026
12cbb24
fix: remove accidental prettier runtime dependency from lockfile
lizradway Jul 8, 2026
cf7a9c9
Merge remote-tracking branch 'origin/main' into storage
lizradway Jul 10, 2026
d0d5a77
refactor(storage): address review feedback
lizradway Jul 10, 2026
3cfd665
refactor(storage): inline namespace and normalize into storage.ts
lizradway Jul 10, 2026
c96f37f
refactor(offloader): pass evictAfterCycles through _bind() instead of…
lizradway Jul 10, 2026
9684436
refactor(storage): remove public .namespace() methods from concrete c…
lizradway Jul 13, 2026
01419eb
Merge branch 'main' into storage
lizradway Jul 13, 2026
9406fac
fix(storage): skip chmod-based test on Windows
lizradway Jul 13, 2026
1c215ed
refactor(storage): remove @deprecated from SnapshotStorageAdapter
lizradway Jul 13, 2026
5230716
refactor(storage): add optional .namespace() to Storage interface
lizradway Jul 13, 2026
6ff1275
Merge branch 'main' into storage
lizradway Jul 13, 2026
823e36f
docs(storage): fix stale comments and simplify namespace return type
lizradway Jul 13, 2026
a0a3be9
Merge branch 'main' into storage
yonib05 Jul 13, 2026
6b16e97
feat(storage): add NAMESPACED symbol for construct auto-prefix detection
lizradway Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions strands-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,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"
Expand Down
2 changes: 2 additions & 0 deletions strands-ts/src/__fixtures__/agent-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
})
Expand Down
2 changes: 1 addition & 1 deletion strands-ts/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions strands-ts/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,25 @@ export class DefaultNotConfiguredError extends Error {
}
}

/**
* 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'
}
}

/**
* Error thrown when checkpoint operations fail (e.g. an incompatible schema
* version when resuming a durable run). See the experimental checkpoint module.
Expand Down
4 changes: 4 additions & 0 deletions strands-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export {
StructuredOutputError,
ToolNotFoundError,
DefaultNotConfiguredError,
StorageError,
} from './errors.js'

// Interrupt system
Expand Down Expand Up @@ -306,6 +307,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'
Expand Down
297 changes: 297 additions & 0 deletions strands-ts/src/session/__tests__/snapshot-storage-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
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/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> = {}): 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()
adapter = new SnapshotStorageAdapter(namespace(backend, 'session'))
})

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('session/')
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('session/')
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('session/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('session/session-1/')
const keys2 = await backend.list('session/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 namespace', () => {
it('uses the namespace provided to the adapter', async () => {
const customAdapter = new SnapshotStorageAdapter(namespace(backend, 'custom/prefix'))
const location = createLocation()

await customAdapter.saveSnapshot({
location,
snapshotId: uuidV7(1),
isLatest: true,
snapshot: createTestSnapshot(),
})

const defaultKeys = await backend.list('session/')
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()
failingBackend.write = async () => {
throw new Error('disk full')
}
const failAdapter = new SnapshotStorageAdapter(namespace(failingBackend, 'session'))
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()
failingBackend.read = async () => {
throw new Error('network timeout')
}
const failAdapter = new SnapshotStorageAdapter(namespace(failingBackend, 'session'))
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 = 'session/test-session/scopes/agent/test-agent/snapshots/snapshot_latest.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/)
})
})
})
Loading
Loading