From 431557e7e785f5dbfc83dc8a2c8874c1967a013e Mon Sep 17 00:00:00 2001 From: opieter-aws Date: Mon, 1 Jun 2026 14:51:59 -0400 Subject: [PATCH 1/6] feat(memory): add memory manager --- strands-ts/src/agent/agent.ts | 19 + strands-ts/src/index.ts | 13 + .../memory/__tests__/memory-manager.test.ts | 548 ++++++++++++++++++ strands-ts/src/memory/index.ts | 11 + strands-ts/src/memory/memory-manager.ts | 369 ++++++++++++ strands-ts/src/memory/types.ts | 136 +++++ 6 files changed, 1096 insertions(+) create mode 100644 strands-ts/src/memory/__tests__/memory-manager.test.ts create mode 100644 strands-ts/src/memory/index.ts create mode 100644 strands-ts/src/memory/memory-manager.ts create mode 100644 strands-ts/src/memory/types.ts diff --git a/strands-ts/src/agent/agent.ts b/strands-ts/src/agent/agent.ts index 7fb3d6484..ba9c322a4 100644 --- a/strands-ts/src/agent/agent.ts +++ b/strands-ts/src/agent/agent.ts @@ -74,6 +74,8 @@ import { ToolCaller } from './tool-caller.js' import type { ToolCallerProxy } from './tool-caller.js' import type { z } from 'zod' +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' @@ -199,6 +201,12 @@ export type AgentConfig = { * Session manager for saving and restoring agent sessions */ sessionManager?: SessionManager + /** + * Memory manager for cross-session memory retrieval and storage. + * Manages one or more memory stores and exposes search/add tools. + * Accepts a {@link MemoryManager} instance or a {@link MemoryManagerConfig} object (auto-wrapped). + */ + memoryManager?: MemoryManager | MemoryManagerConfig /** * Custom trace attributes to include in all spans. * These attributes are merged with standard attributes in telemetry spans. @@ -288,6 +296,10 @@ export class Agent implements LocalAgent, InvokableAgent { * The session manager for saving and restoring agent sessions, if configured. */ public readonly sessionManager?: SessionManager | undefined + /** + * The memory manager for cross-session memory retrieval and storage, if configured. + */ + public readonly memoryManager?: MemoryManager | undefined private readonly _hooksRegistry: HookRegistryImplementation private readonly _pluginRegistry: PluginRegistry @@ -324,6 +336,12 @@ export class Agent implements LocalAgent, InvokableAgent { this.id = config?.id ?? DEFAULT_AGENT_ID if (config?.description !== undefined) this.description = config.description this.sessionManager = config?.sessionManager + this.memoryManager = + config?.memoryManager instanceof MemoryManager + ? config.memoryManager + : config?.memoryManager + ? new MemoryManager(config.memoryManager) + : undefined if (typeof config?.model === 'string') { this.model = new BedrockModel({ modelId: config.model }) @@ -376,6 +394,7 @@ export class Agent implements LocalAgent, InvokableAgent { this._conversationManager, ...retryStrategies, ...(config?.plugins ?? []), + ...(this.memoryManager ? [this.memoryManager] : []), ...(config?.sessionManager ? [config.sessionManager] : []), new ModelPlugin(this.model), ]) diff --git a/strands-ts/src/index.ts b/strands-ts/src/index.ts index 46b1022c0..339abd3ad 100644 --- a/strands-ts/src/index.ts +++ b/strands-ts/src/index.ts @@ -310,3 +310,16 @@ export type { StreamType, StreamChunk, FileInfo, OutputFile, ExecutionResult } f // Multi-agent orchestration export { Graph } from './multiagent/index.js' export { Swarm } from './multiagent/index.js' + +// Memory management +export { MemoryManager } from './memory/index.js' +export type { + MemoryEntry, + MemoryStore, + MemoryStoreConfig, + SearchOptions, + SearchMemoryOptions, + AddMemoryOptions, + MemoryToolConfig, + MemoryManagerConfig, +} from './memory/index.js' diff --git a/strands-ts/src/memory/__tests__/memory-manager.test.ts b/strands-ts/src/memory/__tests__/memory-manager.test.ts new file mode 100644 index 000000000..165674a57 --- /dev/null +++ b/strands-ts/src/memory/__tests__/memory-manager.test.ts @@ -0,0 +1,548 @@ +import { describe, it, expect, vi } from 'vitest' +import { z } from 'zod' +import { Agent } from '../../agent/agent.js' +import { MemoryManager } from '../memory-manager.js' +import { tool } from '../../tools/tool-factory.js' +import type { MemoryStore, MemoryEntry } from '../types.js' +import type { InvokableTool, Tool } from '../../tools/tool.js' +import { logger } from '../../logging/logger.js' + +function createMockStore( + name: string, + options?: { + entries?: MemoryEntry[] + writable?: boolean + description?: string + maxSearchResults?: number + tools?: Tool[] + } +): MemoryStore { + const store: MemoryStore = { + name, + writable: !!options?.writable, + ...(options?.description && { description: options.description }), + ...(options?.maxSearchResults != null && { maxSearchResults: options.maxSearchResults }), + search: vi.fn().mockResolvedValue(options?.entries ?? []), + } + if (options?.writable) { + store.add = vi.fn().mockResolvedValue(undefined) + } + if (options?.tools) { + store.getTools = vi.fn().mockReturnValue(options.tools) + } + return store +} + +function createNamedTool(name: string): Tool { + return tool({ + name, + description: `test tool ${name}`, + inputSchema: z.object({}), + callback: () => 'ok', + }) +} + +describe('MemoryManager', () => { + describe('constructor', () => { + it('throws when stores array is empty', () => { + expect(() => new MemoryManager({ stores: [] })).toThrow('at least one store is required') + }) + + it('creates instance with valid config', () => { + const mm = new MemoryManager({ stores: [createMockStore('test')] }) + expect(mm.name).toBe('strands:memory-manager') + }) + + it('throws when two stores share a name', () => { + expect(() => new MemoryManager({ stores: [createMockStore('dup'), createMockStore('dup')] })).toThrow( + "duplicate store name 'dup'" + ) + }) + + it('throws when a store is writable but has no add method', () => { + const broken: MemoryStore = { name: 'broken', writable: true, search: vi.fn().mockResolvedValue([]) } + expect(() => new MemoryManager({ stores: [broken] })).toThrow("store 'broken' is writable but has no add method") + }) + + it('throws when addToolConfig is enabled but no stores are writable', () => { + expect( + () => + new MemoryManager({ + stores: [createMockStore('a')], + addToolConfig: true, + }) + ).toThrow('addToolConfig is enabled but no stores are writable') + }) + + it('allows addToolConfig true with a single writable store', () => { + const mm = new MemoryManager({ + stores: [createMockStore('a', { writable: true })], + addToolConfig: true, + }) + expect(mm.getTools().map((t) => t.name)).toContain('add_memory') + }) + + it('allows addToolConfig true with multiple writable stores', () => { + const mm = new MemoryManager({ + stores: [createMockStore('a', { writable: true }), createMockStore('b', { writable: true })], + addToolConfig: true, + }) + expect(mm.getTools().map((t) => t.name)).toContain('add_memory') + }) + }) + + describe('getTools', () => { + it('registers search tool by default', () => { + const mm = new MemoryManager({ stores: [createMockStore('test')] }) + const tools = mm.getTools() + expect(tools).toHaveLength(1) + expect(tools[0]!.name).toBe('search_memory') + }) + + it('registers add tool when addToolConfig is enabled', () => { + const mm = new MemoryManager({ + stores: [createMockStore('test', { writable: true })], + addToolConfig: true, + }) + const tools = mm.getTools() + expect(tools.map((t) => t.name)).toStrictEqual(['search_memory', 'add_memory']) + }) + + it('does not register add tool by default', () => { + const mm = new MemoryManager({ stores: [createMockStore('test', { writable: true })] }) + const tools = mm.getTools() + expect(tools.map((t) => t.name)).toStrictEqual(['search_memory']) + }) + + it('returns empty array when searchToolConfig is false and addToolConfig is false', () => { + const mm = new MemoryManager({ + stores: [createMockStore('test', { writable: true })], + searchToolConfig: false, + addToolConfig: false, + }) + expect(mm.getTools()).toStrictEqual([]) + }) + + it('uses custom tool names from MemoryToolConfig', () => { + const mm = new MemoryManager({ + stores: [createMockStore('test', { writable: true })], + searchToolConfig: { name: 'recall' }, + addToolConfig: { name: 'remember' }, + }) + const tools = mm.getTools() + expect(tools.map((t) => t.name)).toStrictEqual(['recall', 'remember']) + }) + + it('includes store descriptions in search tool description', () => { + const store = createMockStore('personal', { description: 'User preferences' }) + const mm = new MemoryManager({ stores: [store] }) + const tools = mm.getTools() + expect(tools[0]!.description).toContain('personal: User preferences') + expect(tools[0]!.description).toContain('target one or more memory stores by name') + }) + + it('includes store descriptions in add tool description', () => { + const store = createMockStore('notes', { writable: true, description: 'Personal notes' }) + const mm = new MemoryManager({ stores: [store], addToolConfig: true }) + const tools = mm.getTools() + const addTool = tools.find((t) => t.name === 'add_memory')! + expect(addTool.description).toContain('notes: Personal notes') + expect(addTool.description).toContain('target a specific store by name') + }) + + it('aggregates tools provided by stores via getTools', () => { + const store = createMockStore('kb', { tools: [createNamedTool('kb_query')] }) + const mm = new MemoryManager({ stores: [store] }) + + expect(mm.getTools().map((t) => t.name)).toStrictEqual(['search_memory', 'kb_query']) + }) + + it('aggregates store tools across multiple stores alongside the manager tools', () => { + const a = createMockStore('a', { writable: true, tools: [createNamedTool('a_tool')] }) + const b = createMockStore('b', { tools: [createNamedTool('b_tool')] }) + const mm = new MemoryManager({ stores: [a, b], addToolConfig: true }) + + expect(mm.getTools().map((t) => t.name)).toStrictEqual(['search_memory', 'add_memory', 'a_tool', 'b_tool']) + }) + + it('includes store tools even when the manager registers no tools of its own', () => { + const store = createMockStore('kb', { tools: [createNamedTool('kb_query')] }) + const mm = new MemoryManager({ stores: [store], searchToolConfig: false }) + + expect(mm.getTools().map((t) => t.name)).toStrictEqual(['kb_query']) + }) + }) + + describe('search', () => { + it('queries all stores and concatenates results', async () => { + const store1 = createMockStore('a', { entries: [{ content: 'fact one' }] }) + const store2 = createMockStore('b', { entries: [{ content: 'fact two' }] }) + const mm = new MemoryManager({ stores: [store1, store2] }) + + const results = await mm.search('query') + expect(results).toStrictEqual([ + { content: 'fact one', store: 'a' }, + { content: 'fact two', store: 'b' }, + ]) + }) + + it("resolves a store's per-instance maxSearchResults when the caller omits it", async () => { + const store = createMockStore('a', { maxSearchResults: 5 }) + const mm = new MemoryManager({ stores: [store] }) + + await mm.search('query') + expect(store.search).toHaveBeenCalledWith('query', { maxSearchResults: 5 }) + }) + + it('forwards an explicit maxSearchResults override to each store', async () => { + const store = createMockStore('a', { maxSearchResults: 5 }) + const mm = new MemoryManager({ stores: [store] }) + + await mm.search('query', { maxSearchResults: 2 }) + expect(store.search).toHaveBeenCalledWith('query', { maxSearchResults: 2 }) + }) + + it('falls back to the SDK default when neither caller nor store specifies a limit', async () => { + const store = createMockStore('a') + const mm = new MemoryManager({ stores: [store] }) + + await mm.search('query') + expect(store.search).toHaveBeenCalledWith('query', { maxSearchResults: 3 }) + }) + + it('filters to named stores when options.stores is provided', async () => { + const store1 = createMockStore('personal', { entries: [{ content: 'personal fact' }] }) + const store2 = createMockStore('team', { entries: [{ content: 'team fact' }] }) + const mm = new MemoryManager({ stores: [store1, store2] }) + + const results = await mm.search('query', { stores: ['personal'] }) + expect(results).toStrictEqual([{ content: 'personal fact', store: 'personal' }]) + expect(store2.search).not.toHaveBeenCalled() + }) + + it('gracefully handles store failures', async () => { + const store1: MemoryStore = { + name: 'failing', + writable: false, + search: vi.fn().mockRejectedValue(new Error('network error')), + } + const store2 = createMockStore('ok', { entries: [{ content: 'fact' }] }) + const mm = new MemoryManager({ stores: [store1, store2] }) + + const results = await mm.search('query') + expect(results).toStrictEqual([{ content: 'fact', store: 'ok' }]) + }) + + it('searches all stores when stores option is omitted', async () => { + const store1 = createMockStore('a', { entries: [{ content: 'fact one' }] }) + const store2 = createMockStore('b', { entries: [{ content: 'fact two' }] }) + const mm = new MemoryManager({ stores: [store1, store2] }) + + const results = await mm.search('query') + expect(results).toStrictEqual([ + { content: 'fact one', store: 'a' }, + { content: 'fact two', store: 'b' }, + ]) + }) + + it('searches no stores when stores option is an empty array', async () => { + const store1 = createMockStore('a', { entries: [{ content: 'fact one' }] }) + const store2 = createMockStore('b', { entries: [{ content: 'fact two' }] }) + const mm = new MemoryManager({ stores: [store1, store2] }) + + const results = await mm.search('query', { stores: [] }) + expect(results).toStrictEqual([]) + expect(store1.search).not.toHaveBeenCalled() + expect(store2.search).not.toHaveBeenCalled() + }) + + it('throws a not-found error when a named store does not exist', async () => { + const store = createMockStore('personal', { entries: [{ content: 'fact' }] }) + const mm = new MemoryManager({ stores: [store] }) + + await expect(mm.search('query', { stores: ['nonexistent'] })).rejects.toThrow("store 'nonexistent' not found") + expect(store.search).not.toHaveBeenCalled() + }) + }) + + describe('add', () => { + it('writes to all writable stores', async () => { + const store1 = createMockStore('a', { writable: true }) + const store2 = createMockStore('b', { writable: true }) + const mm = new MemoryManager({ stores: [store1, store2] }) + + await mm.add('user likes coffee') + expect(store1.add).toHaveBeenCalledWith('user likes coffee', undefined) + expect(store2.add).toHaveBeenCalledWith('user likes coffee', undefined) + }) + + it('passes metadata to stores', async () => { + const store = createMockStore('a', { writable: true }) + const mm = new MemoryManager({ stores: [store] }) + + await mm.add('fact', { metadata: { source: 'user' } }) + expect(store.add).toHaveBeenCalledWith('fact', { source: 'user' }) + }) + + it('filters to named stores when options.stores is provided', async () => { + const store1 = createMockStore('personal', { writable: true }) + const store2 = createMockStore('team', { writable: true }) + const mm = new MemoryManager({ stores: [store1, store2] }) + + await mm.add('my preference', { stores: ['personal'] }) + expect(store1.add).toHaveBeenCalledWith('my preference', undefined) + expect(store2.add).not.toHaveBeenCalled() + }) + + it('dedupes duplicate store names so each store is written once', async () => { + const store = createMockStore('personal', { writable: true }) + const mm = new MemoryManager({ stores: [store] }) + + await mm.add('fact', { stores: ['personal', 'personal'] }) + expect(store.add).toHaveBeenCalledTimes(1) + }) + + it('throws when no writable stores match', async () => { + const mm = new MemoryManager({ stores: [createMockStore('a')] }) + await expect(mm.add('fact')).rejects.toThrow('no writable store matched') + }) + + it('throws a not-found error when a named store does not exist', async () => { + const mm = new MemoryManager({ stores: [createMockStore('a', { writable: true })] }) + await expect(mm.add('fact', { stores: ['nonexistent'] })).rejects.toThrow("store 'nonexistent' not found") + }) + + it('throws a read-only error when a named store cannot be written', async () => { + const mm = new MemoryManager({ stores: [createMockStore('readonly')] }) + await expect(mm.add('fact', { stores: ['readonly'] })).rejects.toThrow("store 'readonly' is read-only") + }) + + it('succeeds with partial write failures (some stores fail, some succeed)', async () => { + const store1: MemoryStore = { + name: 'failing', + writable: true, + search: vi.fn().mockResolvedValue([]), + add: vi.fn().mockRejectedValue(new Error('write error')), + } + const store2 = createMockStore('ok', { writable: true }) + const mm = new MemoryManager({ stores: [store1, store2] }) + + await mm.add('fact') + expect(store2.add).toHaveBeenCalledWith('fact', undefined) + }) + + it('throws AggregateError naming the failed stores when all writes fail', async () => { + const store: MemoryStore = { + name: 'failing', + writable: true, + search: vi.fn().mockResolvedValue([]), + add: vi.fn().mockRejectedValue(new Error('write error')), + } + const mm = new MemoryManager({ stores: [store] }) + + await expect(mm.add('fact')).rejects.toThrow('all store writes failed: failing') + }) + }) + + describe('tool store scoping', () => { + function searchTool( + mm: MemoryManager + ): InvokableTool<{ query: string; maxSearchResults?: number; stores?: string[] }, unknown> { + return mm.getTools().find((t) => t.name === 'search_memory') as never + } + + function addTool(mm: MemoryManager): InvokableTool<{ entries: string[]; stores?: string[] }, unknown> { + return mm.getTools().find((t) => t.name === 'add_memory') as never + } + + it('search tool queries all stores when model omits stores', async () => { + const personal = createMockStore('personal', { entries: [{ content: 'personal fact' }] }) + const team = createMockStore('team', { entries: [{ content: 'team fact' }] }) + const mm = new MemoryManager({ stores: [personal, team] }) + + await searchTool(mm).invoke({ query: 'q' }) + expect(personal.search).toHaveBeenCalled() + expect(team.search).toHaveBeenCalled() + }) + + it('search tool treats an empty stores array as omitted (searches all)', async () => { + const personal = createMockStore('personal', { entries: [{ content: 'personal fact' }] }) + const team = createMockStore('team', { entries: [{ content: 'team fact' }] }) + const mm = new MemoryManager({ stores: [personal, team] }) + + await searchTool(mm).invoke({ query: 'q', stores: [] }) + expect(personal.search).toHaveBeenCalled() + expect(team.search).toHaveBeenCalled() + }) + + it('search tool targets only the requested store when in scope', async () => { + const personal = createMockStore('personal', { entries: [{ content: 'personal fact' }] }) + const team = createMockStore('team', { entries: [{ content: 'team fact' }] }) + const mm = new MemoryManager({ stores: [personal, team] }) + + await searchTool(mm).invoke({ query: 'q', stores: ['personal'] }) + expect(personal.search).toHaveBeenCalled() + expect(team.search).not.toHaveBeenCalled() + }) + + it('search tool result attributes each entry to its source store', async () => { + const personal = createMockStore('personal', { entries: [{ content: 'personal fact' }] }) + const team = createMockStore('team', { entries: [{ content: 'team fact' }] }) + const mm = new MemoryManager({ stores: [personal, team] }) + + const result = await searchTool(mm).invoke({ query: 'q' }) + expect(result).toStrictEqual([ + { content: 'personal fact', store: 'personal' }, + { content: 'team fact', store: 'team' }, + ]) + }) + + it('search tool keeps valid names and warns on out-of-scope names', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const personal = createMockStore('personal', { entries: [{ content: 'personal fact' }] }) + const team = createMockStore('team', { entries: [{ content: 'team fact' }] }) + const mm = new MemoryManager({ stores: [personal, team] }) + + await searchTool(mm).invoke({ query: 'q', stores: ['personal', 'nonexistent'] }) + expect(personal.search).toHaveBeenCalled() + expect(team.search).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('nonexistent')) + warnSpy.mockRestore() + }) + + it('search tool throws when every requested store is out of scope', async () => { + const personal = createMockStore('personal', { entries: [{ content: 'personal fact' }] }) + const mm = new MemoryManager({ stores: [personal] }) + + await expect(searchTool(mm).invoke({ query: 'q', stores: ['nonexistent'] })).rejects.toThrow( + 'none of the requested memory stores are available' + ) + expect(personal.search).not.toHaveBeenCalled() + }) + + it('add tool writes to all writable stores when model omits stores', async () => { + const personal = createMockStore('personal', { writable: true }) + const team = createMockStore('team', { writable: true }) + const mm = new MemoryManager({ stores: [personal, team], addToolConfig: true }) + + await addTool(mm).invoke({ entries: ['fact'] }) + expect(personal.add).toHaveBeenCalledWith('fact', undefined) + expect(team.add).toHaveBeenCalledWith('fact', undefined) + }) + + it('add tool treats an empty stores array as omitted (writes to all writable)', async () => { + const personal = createMockStore('personal', { writable: true }) + const team = createMockStore('team', { writable: true }) + const mm = new MemoryManager({ stores: [personal, team], addToolConfig: true }) + + await addTool(mm).invoke({ entries: ['fact'], stores: [] }) + expect(personal.add).toHaveBeenCalled() + expect(team.add).toHaveBeenCalled() + }) + + it('add tool excludes read-only stores from its scope', async () => { + const personal = createMockStore('personal', { writable: true }) + const readonly = createMockStore('readonly') + const mm = new MemoryManager({ stores: [personal, readonly], addToolConfig: true }) + + // A read-only store is out of the add tool's scope, so naming it throws. + await expect(addTool(mm).invoke({ entries: ['fact'], stores: ['readonly'] })).rejects.toThrow( + 'none of the requested memory stores are available' + ) + expect(personal.add).not.toHaveBeenCalled() + }) + + it('add tool keeps valid names and warns on out-of-scope names', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const personal = createMockStore('personal', { writable: true }) + const team = createMockStore('team', { writable: true }) + const mm = new MemoryManager({ stores: [personal, team], addToolConfig: true }) + + await addTool(mm).invoke({ entries: ['fact'], stores: ['personal', 'nonexistent'] }) + expect(personal.add).toHaveBeenCalledWith('fact', undefined) + expect(team.add).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('nonexistent')) + warnSpy.mockRestore() + }) + + it('add tool throws when every requested store is out of scope', async () => { + const personal = createMockStore('personal', { writable: true }) + const mm = new MemoryManager({ stores: [personal], addToolConfig: true }) + + await expect(addTool(mm).invoke({ entries: ['fact'], stores: ['nonexistent'] })).rejects.toThrow( + 'none of the requested memory stores are available' + ) + expect(personal.add).not.toHaveBeenCalled() + }) + + it('add tool rejects an empty entries array', async () => { + const personal = createMockStore('personal', { writable: true }) + const mm = new MemoryManager({ stores: [personal], addToolConfig: true }) + + await expect(addTool(mm).invoke({ entries: [] })).rejects.toThrow() + expect(personal.add).not.toHaveBeenCalled() + }) + + it('add tool throws a flattened error (concrete reasons, not nested AggregateErrors) when every entry fails', async () => { + const failing: MemoryStore = { + name: 'failing', + writable: true, + search: vi.fn().mockResolvedValue([]), + add: vi.fn().mockRejectedValue(new Error('write error')), + } + const mm = new MemoryManager({ stores: [failing], addToolConfig: true }) + + const error = await addTool(mm) + .invoke({ entries: ['a', 'b'] }) + .catch((e: unknown) => e) + expect(error).toBeInstanceOf(AggregateError) + const agg = error as AggregateError + expect(agg.message).toContain('failed to add all 2 entries') + expect(agg.message).toContain('write error') + // Leaves are the underlying store errors, not the per-entry AggregateErrors add() throws. + expect(agg.errors).toHaveLength(2) + expect(agg.errors.every((e) => e instanceof Error && !(e instanceof AggregateError))).toBe(true) + }) + + it('add tool returns counts when some entries succeed and some fail', async () => { + const flaky: MemoryStore = { + name: 'flaky', + writable: true, + search: vi.fn().mockResolvedValue([]), + // First entry's write resolves; second entry's write rejects (its only store), so that entry + // fails entirely while the first succeeds — a genuine per-entry partial outcome. + add: vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('write error')), + } + const mm = new MemoryManager({ stores: [flaky], addToolConfig: true }) + + const result = await addTool(mm).invoke({ entries: ['a', 'b'] }) + expect(result).toStrictEqual({ stored: 1, failed: 1 }) + }) + }) + + describe('initAgent', () => { + it('does not throw', () => { + const mm = new MemoryManager({ stores: [createMockStore('test')] }) + expect(() => mm.initAgent({} as any)).not.toThrow() + }) + }) + + describe('AgentConfig integration', () => { + it('auto-wraps MemoryManagerConfig into MemoryManager instance', () => { + const store = createMockStore('test') + const agent = new Agent({ memoryManager: { stores: [store] } }) + expect(agent.memoryManager).toBeInstanceOf(MemoryManager) + }) + + it('passes through MemoryManager instance unchanged', () => { + const mm = new MemoryManager({ stores: [createMockStore('test')] }) + const agent = new Agent({ memoryManager: mm }) + expect(agent.memoryManager).toBe(mm) + }) + + it('sets memoryManager to undefined when not configured', () => { + const agent = new Agent({}) + expect(agent.memoryManager).toBeUndefined() + }) + }) +}) diff --git a/strands-ts/src/memory/index.ts b/strands-ts/src/memory/index.ts new file mode 100644 index 000000000..898e9c393 --- /dev/null +++ b/strands-ts/src/memory/index.ts @@ -0,0 +1,11 @@ +export { MemoryManager } from './memory-manager.js' +export type { + MemoryEntry, + MemoryStore, + MemoryStoreConfig, + SearchOptions, + SearchMemoryOptions, + AddMemoryOptions, + MemoryToolConfig, + MemoryManagerConfig, +} from './types.js' diff --git a/strands-ts/src/memory/memory-manager.ts b/strands-ts/src/memory/memory-manager.ts new file mode 100644 index 000000000..601d1d215 --- /dev/null +++ b/strands-ts/src/memory/memory-manager.ts @@ -0,0 +1,369 @@ +import type { Plugin } from '../plugins/plugin.js' +import type { LocalAgent } from '../types/agent.js' +import type { Tool } from '../tools/tool.js' +import type { + MemoryEntry, + MemoryManagerConfig, + SearchMemoryOptions, + MemoryStore, + AddMemoryOptions, + MemoryToolConfig, +} from './types.js' +import type { JSONValue } from '../types/json.js' +import { tool } from '../tools/tool-factory.js' +import { z } from 'zod' +import { logger } from '../logging/logger.js' +import { normalizeError } from '../errors.js' + +const SEARCH_TOOL_DESCRIPTION = + 'Search long-term memory for facts, preferences, or context from previous conversations. Use when you need background about the user or topic that may have been discussed before.' + +const ADD_TOOL_DESCRIPTION = + 'Add facts, preferences, or decisions to long-term memory so they are remembered across conversations. Use when the user shares something worth recalling later.' + +/** + * Default maximum results per store when neither the caller nor the store specifies one. + * Resolved by the {@link MemoryManager}. + */ +export const DEFAULT_MAX_SEARCH_RESULTS = 3 + +/** Flattens nested AggregateErrors so the leaves are concrete reasons, not errors-of-errors. */ +function _flattenReasons(reasons: unknown[]): unknown[] { + return reasons.flatMap((reason) => (reason instanceof AggregateError ? _flattenReasons(reason.errors) : [reason])) +} + +/** + * Provides cross-session memory retrieval and storage for agents. + * + * Manages one or more {@link MemoryStore} backends, exposing `search_memory` and + * `add_memory` tools for agent-driven recall and persistence. Any tools the stores + * themselves provide (via {@link MemoryStore.getTools}) are registered alongside these. + * + * @example + * ```typescript + * import { Agent, MemoryManager } from '@strands-agents/sdk' + * + * // Config shorthand + * const agent = new Agent({ + * model, + * memoryManager: { stores: [myStore], addToolConfig: true }, + * }) + * + * // Class instance (for programmatic access) + * const memoryManager = new MemoryManager({ stores: [myStore], addToolConfig: true }) + * const agent = new Agent({ model, memoryManager }) + * await memoryManager.search('user preferences') + * ``` + */ +export class MemoryManager implements Plugin { + readonly name = 'strands:memory-manager' + private readonly _config: MemoryManagerConfig + private readonly _searchStores: MemoryStore[] + private readonly _addStores: MemoryStore[] + private readonly _searchToolConfig: MemoryToolConfig | false + private readonly _addToolConfig: MemoryToolConfig | false + + constructor(config: MemoryManagerConfig) { + if (config.stores.length === 0) { + throw new Error('MemoryManager: at least one store is required') + } + + const seenNames = new Set() + for (const store of config.stores) { + if (seenNames.has(store.name)) { + throw new Error(`MemoryManager: duplicate store name '${store.name}'`) + } + seenNames.add(store.name) + + if (store.writable && !store.add) { + throw new Error(`MemoryManager: store '${store.name}' is writable but has no add method`) + } + } + + this._config = config + this._searchStores = config.stores + this._addStores = config.stores.filter((s) => s.writable) + + this._searchToolConfig = + config.searchToolConfig === false + ? false + : typeof config.searchToolConfig === 'object' + ? config.searchToolConfig + : {} + + if (config.addToolConfig === undefined || config.addToolConfig === false) { + this._addToolConfig = false + } else { + if (this._addStores.length === 0) { + throw new Error('MemoryManager: addToolConfig is enabled but no stores are writable') + } + this._addToolConfig = typeof config.addToolConfig === 'object' ? config.addToolConfig : {} + } + } + + /** + * Initializes the plugin with the agent. + * + * No lifecycle hooks are registered in this version. + * + * @param _agent - The agent this plugin is being attached to + */ + initAgent(_agent: LocalAgent): void {} + + /** + * Returns tools registered by this plugin. + * + * Includes the manager's own `search_memory` / `add_memory` tools (per their config) plus any + * tools the configured stores expose via {@link MemoryStore.getTools}. + * + * @returns Array of tools to register with the agent + */ + getTools(): Tool[] { + const tools: Tool[] = [] + + if (this._searchToolConfig !== false) { + tools.push(this._createSearchTool(this._searchToolConfig)) + } + + if (this._addToolConfig !== false) { + tools.push(this._createAddTool(this._addToolConfig)) + } + + for (const store of this._config.stores) { + const storeTools = store.getTools?.() ?? [] + tools.push(...storeTools) + } + + return tools + } + + /** + * Search stores for entries matching the query. If `stores` is provided, only searches to those named stores. + * + * This method is unscoped with full access to all configured stores. + * Tool-level store scoping is applied by the search tool callback. + * When `options.stores` is omitted, all stores are searched. + * + * Only `maxSearchResults` and routing (`stores`) cross this layer. Store-specific search + * parameters (e.g. a Bedrock metadata `filter` or search-type override) are not expressible here + * across heterogeneous stores — set them as per-instance defaults on the store, or call the + * store's own `search()` directly for full control. Per-instance store policy (such as a tenant + * filter) always applies, including when reached through the `search_memory` tool. + * + * @param query - The search query string + * @param options - Optional max results (forwarded to all stores) and store name filter + * @returns Array of memory entries from matching stores + */ + async search(query: string, options?: SearchMemoryOptions): Promise { + logger.debug( + `query=<${query}>, max_search_results=<${options?.maxSearchResults}>, stores=<${options?.stores}> | searching stores` + ) + + const targetStores = + options?.stores !== undefined + ? [...new Set(options.stores)].map((name) => { + const found = this._config.stores.find((s) => s.name === name) + if (!found) { + throw new Error(`MemoryManager: store '${name}' not found`) + } + return found + }) + : this._config.stores + + const settled = await Promise.allSettled( + targetStores.map((store) => + store.search(query, { + maxSearchResults: options?.maxSearchResults ?? store.maxSearchResults ?? DEFAULT_MAX_SEARCH_RESULTS, + }) + ) + ) + + const results: MemoryEntry[] = [] + for (let i = 0; i < settled.length; i++) { + const r = settled[i]! + const storeName = targetStores[i]!.name + if (r.status === 'rejected') { + logger.warn(`store=<${storeName}>, reason=<${normalizeError(r.reason).message}> | store search failed`) + continue + } + for (const entry of r.value) { + // Stamp provenance so callers can tell which store produced each result. + results.push({ ...entry, store: storeName }) + } + } + + logger.debug(`results=<${results.length}> | search complete`) + return results + } + + /** + * Add content to writable stores. If `stores` is provided, only writes to those named stores. + * + * This method is unscoped, with full access to all configured writable stores. + * Tool-level store scoping is applied by the add tool callback. + * When `options.stores` is omitted, all writable stores are targeted. + * + * Partial failures are logged. If all writes fail, throws an `AggregateError`. + * + * @param content - The text content to add + * @param options - Optional metadata and store name filter + */ + async add(content: string, options?: AddMemoryOptions): Promise { + let writableStores: MemoryStore[] + + if (options?.stores !== undefined) { + writableStores = [...new Set(options.stores)].map((name) => { + const found = this._config.stores.find((s) => s.name === name) + if (!found) { + throw new Error(`MemoryManager: store '${name}' not found`) + } + if (!found.writable) { + throw new Error(`MemoryManager: store '${name}' is read-only`) + } + return found + }) + } else { + writableStores = this._config.stores.filter((s) => s.writable) + } + + if (writableStores.length === 0) { + throw new Error('MemoryManager: no writable store matched') + } + + const settled = await Promise.allSettled(writableStores.map((s) => s.add!(content, options?.metadata))) + + const failures: { store: string; reason: unknown }[] = [] + for (let i = 0; i < settled.length; i++) { + const r = settled[i]! + if (r.status === 'rejected') { + const storeName = writableStores[i]!.name + logger.warn(`store=<${storeName}>, reason=<${normalizeError(r.reason).message}> | store write failed`) + failures.push({ store: storeName, reason: r.reason }) + } + } + if (failures.length === writableStores.length) { + throw new AggregateError( + failures.map((f) => f.reason), + `MemoryManager: all store writes failed: ${failures.map((f) => f.store).join(', ')}` + ) + } + } + + /** + * Resolves the store names that a tool callback should target against the tool's scoped set. + * + * - Omitting `requested` targets all scoped stores. + * - Names that are in scope are kept; out-of-scope names are dropped with a warning. + * - When every requested name is out of scope, throws so the model receives an actionable error + * (the tool layer turns the thrown error into a model-visible result it can correct from). + * + * @param scopedNames - Store names available to this tool + * @param requested - Store names the model asked for, if any + * @returns A non-empty list of in-scope store names to target + */ + private _resolveToolTargets(scopedNames: string[], requested?: string[]): string[] { + if (requested === undefined || requested.length === 0) { + return scopedNames + } + + const inScope = requested.filter((name) => scopedNames.includes(name)) + const outOfScope = requested.filter((name) => !scopedNames.includes(name)) + + if (inScope.length === 0) { + throw new Error( + `MemoryManager: requested=<${requested.join(', ')}> | none of the requested memory stores are available; available stores: ${scopedNames.join(', ')}` + ) + } + + if (outOfScope.length > 0) { + logger.warn(`requested=<${outOfScope.join(', ')}> | ignoring memory stores outside this tool's scope`) + } + + return inScope + } + + private _createSearchTool(config: MemoryToolConfig): Tool { + let description = config.description ?? SEARCH_TOOL_DESCRIPTION + const storeDescriptions = this._searchStores + .filter((s) => s.description) + .map((s) => `- ${s.name}: ${s.description}`) + if (storeDescriptions.length > 0) { + description += `\n\nAvailable memory stores:\n${storeDescriptions.join('\n')}` + description += + '\n\nYou can target one or more memory stores by name if you know which domains are relevant, or omit the stores parameter to search all.' + } + + const scopedNames = this._searchStores.map((s) => s.name) + + const inputSchema = z.object({ + query: z.string().describe('What to search for'), + maxSearchResults: z.number().optional().describe('Maximum number of results per store'), + stores: z + .array(z.string()) + .optional() + .describe('Filter to specific stores by name. Omit to search all available stores.'), + }) + + return tool({ + name: config.name ?? 'search_memory', + description, + inputSchema, + callback: async (input) => { + const stores = this._resolveToolTargets(scopedNames, input.stores) + const results = await this.search(input.query, { + ...(input.maxSearchResults != null && { maxSearchResults: input.maxSearchResults }), + stores, + }) + return results.map((entry) => ({ + content: entry.content, + ...(entry.store && { store: entry.store }), + ...(entry.metadata && { metadata: entry.metadata }), + })) as JSONValue + }, + }) + } + + private _createAddTool(config: MemoryToolConfig): Tool { + let description = config.description ?? ADD_TOOL_DESCRIPTION + const storeDescriptions = this._addStores.filter((s) => s.description).map((s) => `- ${s.name}: ${s.description}`) + if (storeDescriptions.length > 0) { + description += `\n\nAvailable writable stores:\n${storeDescriptions.join('\n')}` + description += + '\n\nYou can target a specific store by name to route facts to the right place, or omit to add to all writable stores.' + } + + const scopedNames = this._addStores.map((s) => s.name) + + const inputSchema = z.object({ + entries: z.array(z.string()).min(1).describe('Data to add to long-term memory'), + stores: z + .array(z.string()) + .optional() + .describe('Target specific stores by name. Omit to add to all writable stores.'), + }) + + return tool({ + name: config.name ?? 'add_memory', + description, + inputSchema, + callback: async (input) => { + const stores = this._resolveToolTargets(scopedNames, input.stores) + const settled = await Promise.allSettled(input.entries.map((content) => this.add(content, { stores }))) + const stored = settled.filter((r) => r.status === 'fulfilled').length + const failures = settled.filter((r) => r.status === 'rejected') as PromiseRejectedResult[] + + if (stored === 0 && failures.length > 0) { + // Flatten so the leaves are concrete reasons (add() throws its own AggregateError when all + // of an entry's stores fail), and summarize them in the model-visible message. + const reasons = _flattenReasons(failures.map((f) => f.reason)) + throw new AggregateError( + reasons, + `MemoryManager: failed to add all ${failures.length} entries: ${reasons.map((r) => normalizeError(r).message).join('; ')}` + ) + } + + return { stored, failed: failures.length } as JSONValue + }, + }) + } +} diff --git a/strands-ts/src/memory/types.ts b/strands-ts/src/memory/types.ts new file mode 100644 index 000000000..66fb17d00 --- /dev/null +++ b/strands-ts/src/memory/types.ts @@ -0,0 +1,136 @@ +import type { JSONValue } from '../types/json.js' +import type { Tool } from '../tools/tool.js' + +/** + * A single memory entry retrieved from or stored to a memory store. + */ +export interface MemoryEntry { + /** The textual content of this memory entry. */ + content: string + /** + * Name of the store this entry came from. Populated by {@link MemoryManager.search} so callers + * (and the model, via `search_memory`) can tell which store produced each result and refine + * targeting. Stores need not set this themselves. + */ + store?: string + /** Optional metadata (e.g., score, source, id, timestamp). */ + metadata?: Record +} + +/** + * Options passed to {@link MemoryStore.search}. + * + * Store implementations may extend this with backend-specific fields (e.g. a metadata filter or + * search-type override) in their own `search` signature. Note that {@link MemoryManager.search} + * only forwards the base fields here across its (potentially heterogeneous) stores — to use a + * store's extended options, call that store's `search` directly, or set them as per-instance + * defaults on the store. + */ +export interface SearchOptions { + /** Maximum number of results to return. */ + maxSearchResults?: number +} + +/** + * Declarative properties shared by every memory store and its config. + * + * This is the single source of truth for a store's identity and behavior knobs. Both the runtime + * {@link MemoryStore} interface and concrete store configs extend it, so these fields are declared + * once. Concrete stores add their own backend-specific config fields on top. + */ +export interface MemoryStoreConfig { + /** Identifier for this store, used to target specific stores in search/add tools. Must be unique. */ + readonly name: string + /** Human-readable description of what this store contains. Included in tool descriptions. */ + readonly description?: string + /** + * Default maximum number of results this store returns per search, used when a caller does not + * pass a per-call `maxSearchResults`. + */ + readonly maxSearchResults?: number + /** + * Whether this store accepts writes. Optional at config time (caller intent, defaults to `false`); + * concrete stores resolve it to a definite boolean on the {@link MemoryStore} interface. + * + * @defaultValue false + */ + readonly writable?: boolean +} + +/** + * Interface for a memory store backend. + * + * Extends {@link MemoryStoreConfig} with the runtime methods a store provides. Every store is + * searchable; the resolved `writable` flag declares whether it also accepts writes, which is how + * the {@link MemoryManager} decides where to route them. `search_memory` can query all stores, while + * `add_memory` can only write to `writable` stores. + */ +export interface MemoryStore extends MemoryStoreConfig { + /** + * Whether this store accepts writes. + * - `false`: searchable only; never written to. + * - `true`: searchable and writable. Requires `add` to be implemented. + */ + readonly writable: boolean + /** Search the store for entries matching the query, ordered by relevance. */ + search(query: string, options?: SearchOptions): Promise + /** + * Add content to the store. Required when `writable` is `true`; ignored otherwise. + * A store may implement `add` while declaring `writable: false`, in which case it is never invoked. + */ + add?(content: string, metadata?: Record): Promise + /** + * Returns store-specific tools to register with the agent, through a {@link MemoryManager}. Registers + * tools alongside `search_memory` / `add_memory` tools if enabled on the {@link MemoryManager}. + * Implement to expose backend-specific capabilities (e.g. a store-native query tool). + * Optional, mirrors {@link Plugin.getTools}. + * + * @returns Array of tools provided by this store + */ + getTools?(): Tool[] +} + +/** + * Options for {@link MemoryManager.search}. + */ +export interface SearchMemoryOptions { + /** Maximum number of results per store. */ + maxSearchResults?: number + /** Filter to specific stores by name. Omit to search all. */ + stores?: string[] +} + +/** + * Options for {@link MemoryManager.add}. + */ +export interface AddMemoryOptions { + /** Metadata to associate with the added entry. */ + metadata?: Record + /** Filter to specific writable stores by name. Omit to write to all. */ + stores?: string[] +} + +/** + * Configuration for customizing a memory tool's name or description. + * + * Store targeting is derived from each store's `writable` flag (see {@link MemoryStore}), not + * configured here: `search_memory` targets all stores, `add_memory` targets `writable` stores. + */ +export interface MemoryToolConfig { + /** Custom tool name. */ + name?: string + /** Custom tool description. */ + description?: string +} + +/** + * Configuration for the {@link MemoryManager}. + */ +export interface MemoryManagerConfig { + /** One or more memory stores to manage. */ + stores: MemoryStore[] + /** Search tool configuration. Defaults to `true`. */ + searchToolConfig?: MemoryToolConfig | boolean + /** Add tool configuration. Defaults to `false` (opt-in). */ + addToolConfig?: MemoryToolConfig | boolean +} From 010f39e5bb58f5f79a5f80cd5f49ee1bcdee0272 Mon Sep 17 00:00:00 2001 From: opieter-aws Date: Tue, 2 Jun 2026 07:53:50 -0400 Subject: [PATCH 2/6] Address reviewer comments --- strands-ts/src/memory/memory-manager.ts | 38 ++++++++++++++----------- strands-ts/src/memory/types.ts | 2 +- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/strands-ts/src/memory/memory-manager.ts b/strands-ts/src/memory/memory-manager.ts index 601d1d215..4112ced06 100644 --- a/strands-ts/src/memory/memory-manager.ts +++ b/strands-ts/src/memory/memory-manager.ts @@ -180,13 +180,15 @@ export class MemoryManager implements Plugin { const results: MemoryEntry[] = [] for (let i = 0; i < settled.length; i++) { - const r = settled[i]! + const settledResult = settled[i]! const storeName = targetStores[i]!.name - if (r.status === 'rejected') { - logger.warn(`store=<${storeName}>, reason=<${normalizeError(r.reason).message}> | store search failed`) + if (settledResult.status === 'rejected') { + logger.warn( + `store=<${storeName}>, reason=<${normalizeError(settledResult.reason).message}> | store search failed` + ) continue } - for (const entry of r.value) { + for (const entry of settledResult.value) { // Stamp provenance so callers can tell which store produced each result. results.push({ ...entry, store: storeName }) } @@ -223,28 +225,30 @@ export class MemoryManager implements Plugin { return found }) } else { - writableStores = this._config.stores.filter((s) => s.writable) + writableStores = this._addStores } if (writableStores.length === 0) { throw new Error('MemoryManager: no writable store matched') } - const settled = await Promise.allSettled(writableStores.map((s) => s.add!(content, options?.metadata))) + const settled = await Promise.allSettled(writableStores.map((store) => store.add!(content, options?.metadata))) const failures: { store: string; reason: unknown }[] = [] for (let i = 0; i < settled.length; i++) { - const r = settled[i]! - if (r.status === 'rejected') { + const settledResult = settled[i]! + if (settledResult.status === 'rejected') { const storeName = writableStores[i]!.name - logger.warn(`store=<${storeName}>, reason=<${normalizeError(r.reason).message}> | store write failed`) - failures.push({ store: storeName, reason: r.reason }) + logger.warn( + `store=<${storeName}>, reason=<${normalizeError(settledResult.reason).message}> | store write failed` + ) + failures.push({ store: storeName, reason: settledResult.reason }) } } if (failures.length === writableStores.length) { throw new AggregateError( - failures.map((f) => f.reason), - `MemoryManager: all store writes failed: ${failures.map((f) => f.store).join(', ')}` + failures.map((failure) => failure.reason), + `MemoryManager: all store writes failed: ${failures.map((failure) => failure.store).join(', ')}` ) } } @@ -349,16 +353,18 @@ export class MemoryManager implements Plugin { callback: async (input) => { const stores = this._resolveToolTargets(scopedNames, input.stores) const settled = await Promise.allSettled(input.entries.map((content) => this.add(content, { stores }))) - const stored = settled.filter((r) => r.status === 'fulfilled').length - const failures = settled.filter((r) => r.status === 'rejected') as PromiseRejectedResult[] + const stored = settled.filter((settledResult) => settledResult.status === 'fulfilled').length + const failures = settled.filter( + (settledResult) => settledResult.status === 'rejected' + ) as PromiseRejectedResult[] if (stored === 0 && failures.length > 0) { // Flatten so the leaves are concrete reasons (add() throws its own AggregateError when all // of an entry's stores fail), and summarize them in the model-visible message. - const reasons = _flattenReasons(failures.map((f) => f.reason)) + const reasons = _flattenReasons(failures.map((failure) => failure.reason)) throw new AggregateError( reasons, - `MemoryManager: failed to add all ${failures.length} entries: ${reasons.map((r) => normalizeError(r).message).join('; ')}` + `MemoryManager: failed to add all ${failures.length} entries: ${reasons.map((reason) => normalizeError(reason).message).join('; ')}` ) } diff --git a/strands-ts/src/memory/types.ts b/strands-ts/src/memory/types.ts index 66fb17d00..69b4fe747 100644 --- a/strands-ts/src/memory/types.ts +++ b/strands-ts/src/memory/types.ts @@ -27,7 +27,7 @@ export interface MemoryEntry { * defaults on the store. */ export interface SearchOptions { - /** Maximum number of results to return. */ + /** Maximum number of results to return from this store. */ maxSearchResults?: number } From 85a804597955b6616d4496e89bc9fb653cce9fde Mon Sep 17 00:00:00 2001 From: opieter-aws Date: Tue, 2 Jun 2026 13:30:18 -0400 Subject: [PATCH 3/6] fix: update naming of memory options --- strands-ts/src/index.ts | 4 ++-- .../src/memory/__tests__/memory-manager.test.ts | 16 ++++++++-------- strands-ts/src/memory/index.ts | 4 ++-- strands-ts/src/memory/memory-manager.ts | 12 ++++++------ strands-ts/src/memory/types.ts | 10 +++++----- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/strands-ts/src/index.ts b/strands-ts/src/index.ts index 339abd3ad..76d1c55bb 100644 --- a/strands-ts/src/index.ts +++ b/strands-ts/src/index.ts @@ -318,8 +318,8 @@ export type { MemoryStore, MemoryStoreConfig, SearchOptions, - SearchMemoryOptions, - AddMemoryOptions, + MemorySearchOptions, + MemoryAddOptions, MemoryToolConfig, MemoryManagerConfig, } from './memory/index.js' diff --git a/strands-ts/src/memory/__tests__/memory-manager.test.ts b/strands-ts/src/memory/__tests__/memory-manager.test.ts index 165674a57..e0339fd01 100644 --- a/strands-ts/src/memory/__tests__/memory-manager.test.ts +++ b/strands-ts/src/memory/__tests__/memory-manager.test.ts @@ -181,8 +181,8 @@ describe('MemoryManager', () => { const results = await mm.search('query') expect(results).toStrictEqual([ - { content: 'fact one', store: 'a' }, - { content: 'fact two', store: 'b' }, + { content: 'fact one', storeName: 'a' }, + { content: 'fact two', storeName: 'b' }, ]) }) @@ -216,7 +216,7 @@ describe('MemoryManager', () => { const mm = new MemoryManager({ stores: [store1, store2] }) const results = await mm.search('query', { stores: ['personal'] }) - expect(results).toStrictEqual([{ content: 'personal fact', store: 'personal' }]) + expect(results).toStrictEqual([{ content: 'personal fact', storeName: 'personal' }]) expect(store2.search).not.toHaveBeenCalled() }) @@ -230,7 +230,7 @@ describe('MemoryManager', () => { const mm = new MemoryManager({ stores: [store1, store2] }) const results = await mm.search('query') - expect(results).toStrictEqual([{ content: 'fact', store: 'ok' }]) + expect(results).toStrictEqual([{ content: 'fact', storeName: 'ok' }]) }) it('searches all stores when stores option is omitted', async () => { @@ -240,8 +240,8 @@ describe('MemoryManager', () => { const results = await mm.search('query') expect(results).toStrictEqual([ - { content: 'fact one', store: 'a' }, - { content: 'fact two', store: 'b' }, + { content: 'fact one', storeName: 'a' }, + { content: 'fact two', storeName: 'b' }, ]) }) @@ -392,8 +392,8 @@ describe('MemoryManager', () => { const result = await searchTool(mm).invoke({ query: 'q' }) expect(result).toStrictEqual([ - { content: 'personal fact', store: 'personal' }, - { content: 'team fact', store: 'team' }, + { content: 'personal fact', storeName: 'personal' }, + { content: 'team fact', storeName: 'team' }, ]) }) diff --git a/strands-ts/src/memory/index.ts b/strands-ts/src/memory/index.ts index 898e9c393..8805b1bc1 100644 --- a/strands-ts/src/memory/index.ts +++ b/strands-ts/src/memory/index.ts @@ -4,8 +4,8 @@ export type { MemoryStore, MemoryStoreConfig, SearchOptions, - SearchMemoryOptions, - AddMemoryOptions, + MemorySearchOptions, + MemoryAddOptions, MemoryToolConfig, MemoryManagerConfig, } from './types.js' diff --git a/strands-ts/src/memory/memory-manager.ts b/strands-ts/src/memory/memory-manager.ts index 4112ced06..2a59d6355 100644 --- a/strands-ts/src/memory/memory-manager.ts +++ b/strands-ts/src/memory/memory-manager.ts @@ -4,9 +4,9 @@ import type { Tool } from '../tools/tool.js' import type { MemoryEntry, MemoryManagerConfig, - SearchMemoryOptions, + MemorySearchOptions, MemoryStore, - AddMemoryOptions, + MemoryAddOptions, MemoryToolConfig, } from './types.js' import type { JSONValue } from '../types/json.js' @@ -154,7 +154,7 @@ export class MemoryManager implements Plugin { * @param options - Optional max results (forwarded to all stores) and store name filter * @returns Array of memory entries from matching stores */ - async search(query: string, options?: SearchMemoryOptions): Promise { + async search(query: string, options?: MemorySearchOptions): Promise { logger.debug( `query=<${query}>, max_search_results=<${options?.maxSearchResults}>, stores=<${options?.stores}> | searching stores` ) @@ -190,7 +190,7 @@ export class MemoryManager implements Plugin { } for (const entry of settledResult.value) { // Stamp provenance so callers can tell which store produced each result. - results.push({ ...entry, store: storeName }) + results.push({ ...entry, storeName }) } } @@ -210,7 +210,7 @@ export class MemoryManager implements Plugin { * @param content - The text content to add * @param options - Optional metadata and store name filter */ - async add(content: string, options?: AddMemoryOptions): Promise { + async add(content: string, options?: MemoryAddOptions): Promise { let writableStores: MemoryStore[] if (options?.stores !== undefined) { @@ -320,7 +320,7 @@ export class MemoryManager implements Plugin { }) return results.map((entry) => ({ content: entry.content, - ...(entry.store && { store: entry.store }), + ...(entry.storeName && { storeName: entry.storeName }), ...(entry.metadata && { metadata: entry.metadata }), })) as JSONValue }, diff --git a/strands-ts/src/memory/types.ts b/strands-ts/src/memory/types.ts index 69b4fe747..bcfab0e8c 100644 --- a/strands-ts/src/memory/types.ts +++ b/strands-ts/src/memory/types.ts @@ -12,7 +12,7 @@ export interface MemoryEntry { * (and the model, via `search_memory`) can tell which store produced each result and refine * targeting. Stores need not set this themselves. */ - store?: string + storeName?: string /** Optional metadata (e.g., score, source, id, timestamp). */ metadata?: Record } @@ -92,10 +92,10 @@ export interface MemoryStore extends MemoryStoreConfig { /** * Options for {@link MemoryManager.search}. + * + * Extends the store primitive {@link SearchOptions} with manager-level store routing. */ -export interface SearchMemoryOptions { - /** Maximum number of results per store. */ - maxSearchResults?: number +export interface MemorySearchOptions extends SearchOptions { /** Filter to specific stores by name. Omit to search all. */ stores?: string[] } @@ -103,7 +103,7 @@ export interface SearchMemoryOptions { /** * Options for {@link MemoryManager.add}. */ -export interface AddMemoryOptions { +export interface MemoryAddOptions { /** Metadata to associate with the added entry. */ metadata?: Record /** Filter to specific writable stores by name. Omit to write to all. */ From 64a29bc47c18ff4a65a1b2aaf204de95d01cb3bd Mon Sep 17 00:00:00 2001 From: opieter-aws Date: Tue, 2 Jun 2026 14:11:06 -0400 Subject: [PATCH 4/6] feat: fire and forget mechanism for add --- .../memory/__tests__/memory-manager.test.ts | 85 +++++++++++++------ strands-ts/src/memory/memory-manager.ts | 47 +++++++--- strands-ts/src/memory/types.ts | 17 ++++ 3 files changed, 110 insertions(+), 39 deletions(-) diff --git a/strands-ts/src/memory/__tests__/memory-manager.test.ts b/strands-ts/src/memory/__tests__/memory-manager.test.ts index e0339fd01..95bb4b2d3 100644 --- a/strands-ts/src/memory/__tests__/memory-manager.test.ts +++ b/strands-ts/src/memory/__tests__/memory-manager.test.ts @@ -317,30 +317,47 @@ describe('MemoryManager', () => { await expect(mm.add('fact', { stores: ['readonly'] })).rejects.toThrow("store 'readonly' is read-only") }) - it('succeeds with partial write failures (some stores fail, some succeed)', async () => { - const store1: MemoryStore = { + it('fire-and-forget by default: resolves even when a store write fails', async () => { + const failing: MemoryStore = { name: 'failing', writable: true, search: vi.fn().mockResolvedValue([]), add: vi.fn().mockRejectedValue(new Error('write error')), } - const store2 = createMockStore('ok', { writable: true }) - const mm = new MemoryManager({ stores: [store1, store2] }) + const ok = createMockStore('ok', { writable: true }) + const mm = new MemoryManager({ stores: [failing, ok] }) - await mm.add('fact') - expect(store2.add).toHaveBeenCalledWith('fact', undefined) + // Default awaitWrites=false: dispatched to both, resolves without throwing. + await expect(mm.add('fact')).resolves.toBeUndefined() + expect(failing.add).toHaveBeenCalledWith('fact', undefined) + expect(ok.add).toHaveBeenCalledWith('fact', undefined) }) - it('throws AggregateError naming the failed stores when all writes fail', async () => { - const store: MemoryStore = { + it('awaitWrites: throws AggregateError naming the failed store on partial failure', async () => { + const failing: MemoryStore = { name: 'failing', writable: true, search: vi.fn().mockResolvedValue([]), add: vi.fn().mockRejectedValue(new Error('write error')), } - const mm = new MemoryManager({ stores: [store] }) + const ok = createMockStore('ok', { writable: true }) + const mm = new MemoryManager({ stores: [failing, ok], awaitWrites: true }) + + // Partial failure (failing rejects, ok succeeds) throws when awaited. + await expect(mm.add('fact')).rejects.toThrow('store writes failed: failing') + expect(ok.add).toHaveBeenCalledWith('fact', undefined) + }) + + it('awaitWrites per-call override forces awaiting on an otherwise fire-and-forget manager', async () => { + const failing: MemoryStore = { + name: 'failing', + writable: true, + search: vi.fn().mockResolvedValue([]), + add: vi.fn().mockRejectedValue(new Error('write error')), + } + const mm = new MemoryManager({ stores: [failing] }) - await expect(mm.add('fact')).rejects.toThrow('all store writes failed: failing') + await expect(mm.add('fact', { awaitWrites: true })).rejects.toThrow('store writes failed: failing') }) }) @@ -483,7 +500,15 @@ describe('MemoryManager', () => { expect(personal.add).not.toHaveBeenCalled() }) - it('add tool throws a flattened error (concrete reasons, not nested AggregateErrors) when every entry fails', async () => { + it('add tool returns accepted count by default (fire-and-forget)', async () => { + const store = createMockStore('notes', { writable: true }) + const mm = new MemoryManager({ stores: [store], addToolConfig: true }) + + const result = await addTool(mm).invoke({ entries: ['a', 'b'] }) + expect(result).toStrictEqual({ accepted: 2 }) + }) + + it('add tool returns accepted even when a store write fails (fire-and-forget swallows it)', async () => { const failing: MemoryStore = { name: 'failing', writable: true, @@ -492,32 +517,38 @@ describe('MemoryManager', () => { } const mm = new MemoryManager({ stores: [failing], addToolConfig: true }) + const result = await addTool(mm).invoke({ entries: ['a', 'b'] }) + expect(result).toStrictEqual({ accepted: 2 }) + }) + + it('add tool (awaitWrites) returns stored count when all entries succeed', async () => { + const store = createMockStore('notes', { writable: true }) + const mm = new MemoryManager({ stores: [store], addToolConfig: true, awaitWrites: true }) + + const result = await addTool(mm).invoke({ entries: ['a', 'b'] }) + expect(result).toStrictEqual({ stored: 2 }) + }) + + it('add tool (awaitWrites) throws a flattened error with concrete reasons when entries fail', async () => { + const failing: MemoryStore = { + name: 'failing', + writable: true, + search: vi.fn().mockResolvedValue([]), + add: vi.fn().mockRejectedValue(new Error('write error')), + } + const mm = new MemoryManager({ stores: [failing], addToolConfig: true, awaitWrites: true }) + const error = await addTool(mm) .invoke({ entries: ['a', 'b'] }) .catch((e: unknown) => e) expect(error).toBeInstanceOf(AggregateError) const agg = error as AggregateError - expect(agg.message).toContain('failed to add all 2 entries') + expect(agg.message).toContain('failed to add 2 of 2 entries') expect(agg.message).toContain('write error') // Leaves are the underlying store errors, not the per-entry AggregateErrors add() throws. expect(agg.errors).toHaveLength(2) expect(agg.errors.every((e) => e instanceof Error && !(e instanceof AggregateError))).toBe(true) }) - - it('add tool returns counts when some entries succeed and some fail', async () => { - const flaky: MemoryStore = { - name: 'flaky', - writable: true, - search: vi.fn().mockResolvedValue([]), - // First entry's write resolves; second entry's write rejects (its only store), so that entry - // fails entirely while the first succeeds — a genuine per-entry partial outcome. - add: vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('write error')), - } - const mm = new MemoryManager({ stores: [flaky], addToolConfig: true }) - - const result = await addTool(mm).invoke({ entries: ['a', 'b'] }) - expect(result).toStrictEqual({ stored: 1, failed: 1 }) - }) }) describe('initAgent', () => { diff --git a/strands-ts/src/memory/memory-manager.ts b/strands-ts/src/memory/memory-manager.ts index 2a59d6355..659daf4f3 100644 --- a/strands-ts/src/memory/memory-manager.ts +++ b/strands-ts/src/memory/memory-manager.ts @@ -62,6 +62,7 @@ export class MemoryManager implements Plugin { private readonly _addStores: MemoryStore[] private readonly _searchToolConfig: MemoryToolConfig | false private readonly _addToolConfig: MemoryToolConfig | false + private readonly _awaitWrites: boolean constructor(config: MemoryManagerConfig) { if (config.stores.length === 0) { @@ -83,6 +84,7 @@ export class MemoryManager implements Plugin { this._config = config this._searchStores = config.stores this._addStores = config.stores.filter((s) => s.writable) + this._awaitWrites = config.awaitWrites ?? false this._searchToolConfig = config.searchToolConfig === false @@ -205,10 +207,14 @@ export class MemoryManager implements Plugin { * Tool-level store scoping is applied by the add tool callback. * When `options.stores` is omitted, all writable stores are targeted. * - * Partial failures are logged. If all writes fail, throws an `AggregateError`. + * Target stores are always validated synchronously (an unknown or read-only named store throws + * immediately). The store writes themselves follow `awaitWrites` (resolved from + * {@link MemoryAddOptions.awaitWrites} then {@link MemoryManagerConfig.awaitWrites}. + * - fire-and-forget (default): resolves once writes are dispatched; per-store failures are logged. + * - awaited: resolves after all writes settle and throws an `AggregateError` if any store fails. * * @param content - The text content to add - * @param options - Optional metadata and store name filter + * @param options - Optional metadata, store name filter, and per-call `awaitWrites` override */ async add(content: string, options?: MemoryAddOptions): Promise { let writableStores: MemoryStore[] @@ -232,23 +238,43 @@ export class MemoryManager implements Plugin { throw new Error('MemoryManager: no writable store matched') } - const settled = await Promise.allSettled(writableStores.map((store) => store.add!(content, options?.metadata))) + const write = this._writeToStores(writableStores, content, options?.metadata) + + if (options?.awaitWrites ?? this._awaitWrites) { + await write + } else { + // Fire-and-forget: failures are already logged inside _writeToStores; swallow the rejection + // here so the detached promise never surfaces as an unhandled rejection. + write.catch(() => {}) + } + } + + /** + * Writes content to every given store, logging per-store failures. Throws an `AggregateError` if + * any store fails. Callers decide whether to await (observe failures) or fire-and-forget. + */ + private async _writeToStores( + stores: MemoryStore[], + content: string, + metadata: Record | undefined + ): Promise { + const settled = await Promise.allSettled(stores.map((store) => store.add!(content, metadata))) const failures: { store: string; reason: unknown }[] = [] for (let i = 0; i < settled.length; i++) { const settledResult = settled[i]! if (settledResult.status === 'rejected') { - const storeName = writableStores[i]!.name + const storeName = stores[i]!.name logger.warn( `store=<${storeName}>, reason=<${normalizeError(settledResult.reason).message}> | store write failed` ) failures.push({ store: storeName, reason: settledResult.reason }) } } - if (failures.length === writableStores.length) { + if (failures.length > 0) { throw new AggregateError( failures.map((failure) => failure.reason), - `MemoryManager: all store writes failed: ${failures.map((failure) => failure.store).join(', ')}` + `MemoryManager: store writes failed: ${failures.map((failure) => failure.store).join(', ')}` ) } } @@ -353,22 +379,19 @@ export class MemoryManager implements Plugin { callback: async (input) => { const stores = this._resolveToolTargets(scopedNames, input.stores) const settled = await Promise.allSettled(input.entries.map((content) => this.add(content, { stores }))) - const stored = settled.filter((settledResult) => settledResult.status === 'fulfilled').length const failures = settled.filter( (settledResult) => settledResult.status === 'rejected' ) as PromiseRejectedResult[] - if (stored === 0 && failures.length > 0) { - // Flatten so the leaves are concrete reasons (add() throws its own AggregateError when all - // of an entry's stores fail), and summarize them in the model-visible message. + if (failures.length > 0) { const reasons = _flattenReasons(failures.map((failure) => failure.reason)) throw new AggregateError( reasons, - `MemoryManager: failed to add all ${failures.length} entries: ${reasons.map((reason) => normalizeError(reason).message).join('; ')}` + `MemoryManager: failed to add ${failures.length} of ${input.entries.length} entries: ${reasons.map((reason) => normalizeError(reason).message).join('; ')}` ) } - return { stored, failed: failures.length } as JSONValue + return (this._awaitWrites ? { stored: input.entries.length } : { accepted: input.entries.length }) as JSONValue }, }) } diff --git a/strands-ts/src/memory/types.ts b/strands-ts/src/memory/types.ts index bcfab0e8c..9bf9b6531 100644 --- a/strands-ts/src/memory/types.ts +++ b/strands-ts/src/memory/types.ts @@ -108,6 +108,13 @@ export interface MemoryAddOptions { metadata?: Record /** Filter to specific writable stores by name. Omit to write to all. */ stores?: string[] + /** + * Whether to await the store writes before resolving. Overrides the manager's + * {@link MemoryManagerConfig.awaitWrites} default for this call. + * - `false`: fire-and-forget — resolves once writes are dispatched; failures are logged. + * - `true`: awaits all writes and throws an `AggregateError` if any store fails. + */ + awaitWrites?: boolean } /** @@ -133,4 +140,14 @@ export interface MemoryManagerConfig { searchToolConfig?: MemoryToolConfig | boolean /** Add tool configuration. Defaults to `false` (opt-in). */ addToolConfig?: MemoryToolConfig | boolean + /** + * Whether write operations await each store before resolving. Defaults to `false`. + * - `false` (default): fire-and-forget. Writes are dispatched and the call resolves immediately so + * a slow backend never blocks the agent loop; per-store failures are logged, not thrown. + * - `true`: awaits all writes. Throws an `AggregateError` if any targeted store fails, so partial + * failures are observable. + * + * Per-call overridable via {@link MemoryAddOptions.awaitWrites}. + */ + awaitWrites?: boolean } From 365ff9ac5c37c9de786f1f6002d583ab6c1adbc6 Mon Sep 17 00:00:00 2001 From: opieter-aws Date: Tue, 2 Jun 2026 15:30:44 -0400 Subject: [PATCH 5/6] feat: make writable stores for add tool configurable --- strands-ts/src/index.ts | 1 + .../memory/__tests__/memory-manager.test.ts | 71 +++++++++++++++++++ strands-ts/src/memory/index.ts | 1 + strands-ts/src/memory/memory-manager.ts | 42 +++++++++-- strands-ts/src/memory/types.ts | 23 ++++-- 5 files changed, 128 insertions(+), 10 deletions(-) diff --git a/strands-ts/src/index.ts b/strands-ts/src/index.ts index 76d1c55bb..16e1244f5 100644 --- a/strands-ts/src/index.ts +++ b/strands-ts/src/index.ts @@ -321,5 +321,6 @@ export type { MemorySearchOptions, MemoryAddOptions, MemoryToolConfig, + MemoryAddToolConfig, MemoryManagerConfig, } from './memory/index.js' diff --git a/strands-ts/src/memory/__tests__/memory-manager.test.ts b/strands-ts/src/memory/__tests__/memory-manager.test.ts index 95bb4b2d3..e35cb3074 100644 --- a/strands-ts/src/memory/__tests__/memory-manager.test.ts +++ b/strands-ts/src/memory/__tests__/memory-manager.test.ts @@ -89,6 +89,53 @@ describe('MemoryManager', () => { }) expect(mm.getTools().map((t) => t.name)).toContain('add_memory') }) + + it('throws when addToolConfig.stores names a non-existent store', () => { + expect( + () => + new MemoryManager({ + stores: [createMockStore('a', { writable: true })], + addToolConfig: { stores: ['nonexistent'] }, + }) + ).toThrow("addToolConfig store 'nonexistent' not found") + }) + + it('throws when addToolConfig.stores names a non-writable store', () => { + expect( + () => + new MemoryManager({ + stores: [createMockStore('a', { writable: true }), createMockStore('readonly')], + addToolConfig: { stores: ['readonly'] }, + }) + ).toThrow("addToolConfig store 'readonly' is not writable") + }) + + it('accepts MemoryStore instances (not just names) in addToolConfig.stores', async () => { + const personal = createMockStore('personal', { writable: true }) + const team = createMockStore('team', { writable: true }) + // Pass the store instance instead of its name; resolves by name to scope the tool to it. + const mm = new MemoryManager({ stores: [personal, team], addToolConfig: { stores: [personal] } }) + + const addTool = mm.getTools().find((t) => t.name === 'add_memory') as InvokableTool< + { entries: string[]; stores?: string[] }, + unknown + > + await addTool.invoke({ entries: ['fact'] }) + expect(personal.add).toHaveBeenCalledWith('fact', undefined) + expect(team.add).not.toHaveBeenCalled() + }) + + it('throws when an addToolConfig.stores instance is not a configured store', () => { + const configured = createMockStore('configured', { writable: true }) + const stray = createMockStore('stray', { writable: true }) + expect( + () => + new MemoryManager({ + stores: [configured], + addToolConfig: { stores: [stray] }, + }) + ).toThrow("addToolConfig store 'stray' not found") + }) }) describe('getTools', () => { @@ -457,6 +504,30 @@ describe('MemoryManager', () => { expect(team.add).toHaveBeenCalled() }) + it('add tool is scoped to addToolConfig.stores (excludes other writable stores)', async () => { + const personal = createMockStore('personal', { writable: true }) + const team = createMockStore('team', { writable: true }) + const mm = new MemoryManager({ stores: [personal, team], addToolConfig: { stores: ['personal'] } }) + + // Omitting stores writes to the configured allowlist only — not every writable store. + await addTool(mm).invoke({ entries: ['fact'] }) + expect(personal.add).toHaveBeenCalledWith('fact', undefined) + expect(team.add).not.toHaveBeenCalled() + }) + + it('add tool rejects a writable store excluded from addToolConfig.stores (extraction-only store)', async () => { + // `extractionOnly` is writable (e.g. to receive extraction writes) but excluded from the tool's + // allowlist, so the agent's add_memory tool cannot write to it. + const personal = createMockStore('personal', { writable: true }) + const extractionOnly = createMockStore('extraction-only', { writable: true }) + const mm = new MemoryManager({ stores: [personal, extractionOnly], addToolConfig: { stores: ['personal'] } }) + + await expect(addTool(mm).invoke({ entries: ['fact'], stores: ['extraction-only'] })).rejects.toThrow( + 'none of the requested memory stores are available' + ) + expect(extractionOnly.add).not.toHaveBeenCalled() + }) + it('add tool excludes read-only stores from its scope', async () => { const personal = createMockStore('personal', { writable: true }) const readonly = createMockStore('readonly') diff --git a/strands-ts/src/memory/index.ts b/strands-ts/src/memory/index.ts index 8805b1bc1..cf8304ec0 100644 --- a/strands-ts/src/memory/index.ts +++ b/strands-ts/src/memory/index.ts @@ -7,5 +7,6 @@ export type { MemorySearchOptions, MemoryAddOptions, MemoryToolConfig, + MemoryAddToolConfig, MemoryManagerConfig, } from './types.js' diff --git a/strands-ts/src/memory/memory-manager.ts b/strands-ts/src/memory/memory-manager.ts index 659daf4f3..aac9292c2 100644 --- a/strands-ts/src/memory/memory-manager.ts +++ b/strands-ts/src/memory/memory-manager.ts @@ -8,6 +8,7 @@ import type { MemoryStore, MemoryAddOptions, MemoryToolConfig, + MemoryAddToolConfig, } from './types.js' import type { JSONValue } from '../types/json.js' import { tool } from '../tools/tool-factory.js' @@ -59,7 +60,10 @@ export class MemoryManager implements Plugin { readonly name = 'strands:memory-manager' private readonly _config: MemoryManagerConfig private readonly _searchStores: MemoryStore[] + /** All writable stores — the unscoped target set for the programmatic {@link add} method. */ private readonly _addStores: MemoryStore[] + /** Writable stores the `add_memory` tool may write to (a subset of `_addStores`). */ + private readonly _addToolStores: MemoryStore[] private readonly _searchToolConfig: MemoryToolConfig | false private readonly _addToolConfig: MemoryToolConfig | false private readonly _awaitWrites: boolean @@ -95,14 +99,41 @@ export class MemoryManager implements Plugin { if (config.addToolConfig === undefined || config.addToolConfig === false) { this._addToolConfig = false + this._addToolStores = [] } else { if (this._addStores.length === 0) { throw new Error('MemoryManager: addToolConfig is enabled but no stores are writable') } - this._addToolConfig = typeof config.addToolConfig === 'object' ? config.addToolConfig : {} + const toolConfig: MemoryAddToolConfig = typeof config.addToolConfig === 'object' ? config.addToolConfig : {} + this._addToolConfig = toolConfig + this._addToolStores = this._resolveAddToolStores(toolConfig) } } + /** + * Resolves the writable stores the `add_memory` tool may write to. When `stores` is given, each + * entry (a store name or a {@link MemoryStore} instance) must resolve by name to a configured, + * writable store (else throws). Omitted means all writable stores. + */ + private _resolveAddToolStores(toolConfig: MemoryAddToolConfig): MemoryStore[] { + if (toolConfig.stores === undefined) { + return this._addStores + } + + const names = toolConfig.stores.map((store) => (typeof store === 'string' ? store : store.name)) + + return [...new Set(names)].map((name) => { + const found = this._config.stores.find((s) => s.name === name) + if (!found) { + throw new Error(`MemoryManager: addToolConfig store '${name}' not found`) + } + if (!found.writable) { + throw new Error(`MemoryManager: addToolConfig store '${name}' is not writable`) + } + return found + }) + } + /** * Initializes the plugin with the agent. * @@ -191,7 +222,6 @@ export class MemoryManager implements Plugin { continue } for (const entry of settledResult.value) { - // Stamp provenance so callers can tell which store produced each result. results.push({ ...entry, storeName }) } } @@ -355,14 +385,16 @@ export class MemoryManager implements Plugin { private _createAddTool(config: MemoryToolConfig): Tool { let description = config.description ?? ADD_TOOL_DESCRIPTION - const storeDescriptions = this._addStores.filter((s) => s.description).map((s) => `- ${s.name}: ${s.description}`) + const storeDescriptions = this._addToolStores + .filter((s) => s.description) + .map((s) => `- ${s.name}: ${s.description}`) if (storeDescriptions.length > 0) { description += `\n\nAvailable writable stores:\n${storeDescriptions.join('\n')}` description += - '\n\nYou can target a specific store by name to route facts to the right place, or omit to add to all writable stores.' + '\n\nYou can target a specific store by name to route facts to the right place, or omit to add to all available writable stores.' } - const scopedNames = this._addStores.map((s) => s.name) + const scopedNames = this._addToolStores.map((s) => s.name) const inputSchema = z.object({ entries: z.array(z.string()).min(1).describe('Data to add to long-term memory'), diff --git a/strands-ts/src/memory/types.ts b/strands-ts/src/memory/types.ts index 9bf9b6531..ada0930bd 100644 --- a/strands-ts/src/memory/types.ts +++ b/strands-ts/src/memory/types.ts @@ -119,9 +119,6 @@ export interface MemoryAddOptions { /** * Configuration for customizing a memory tool's name or description. - * - * Store targeting is derived from each store's `writable` flag (see {@link MemoryStore}), not - * configured here: `search_memory` targets all stores, `add_memory` targets `writable` stores. */ export interface MemoryToolConfig { /** Custom tool name. */ @@ -130,6 +127,19 @@ export interface MemoryToolConfig { description?: string } +/** + * Configuration for the `add_memory` tool. Extends {@link MemoryToolConfig} with an explicit + * allowlist of stores the tool may write to. + */ +export interface MemoryAddToolConfig extends MemoryToolConfig { + /** + * The writable stores the `add_memory` tool may write to, given as store names or the + * {@link MemoryStore} instances themselves. Each must be a configured, `writable` store. + * Omit (or set `addToolConfig: true`) to allow all writable stores. + */ + stores?: (string | MemoryStore)[] +} + /** * Configuration for the {@link MemoryManager}. */ @@ -138,8 +148,11 @@ export interface MemoryManagerConfig { stores: MemoryStore[] /** Search tool configuration. Defaults to `true`. */ searchToolConfig?: MemoryToolConfig | boolean - /** Add tool configuration. Defaults to `false` (opt-in). */ - addToolConfig?: MemoryToolConfig | boolean + /** + * Add tool configuration. Defaults to `false` (opt-in). `true` lets the tool write to all + * writable stores; pass a {@link MemoryAddToolConfig} with `stores` to restrict it to specific ones. + */ + addToolConfig?: MemoryAddToolConfig | boolean /** * Whether write operations await each store before resolving. Defaults to `false`. * - `false` (default): fire-and-forget. Writes are dispatched and the call resolves immediately so From 8eb9b50d715b58d656830a495fcd23001d779e5d Mon Sep 17 00:00:00 2001 From: opieter-aws Date: Tue, 2 Jun 2026 17:11:44 -0400 Subject: [PATCH 6/6] fix: rework add tool config --- .../memory/__tests__/memory-manager.test.ts | 76 +++++++----------- strands-ts/src/memory/memory-manager.ts | 78 +++++++------------ strands-ts/src/memory/types.ts | 27 +++---- 3 files changed, 68 insertions(+), 113 deletions(-) diff --git a/strands-ts/src/memory/__tests__/memory-manager.test.ts b/strands-ts/src/memory/__tests__/memory-manager.test.ts index e35cb3074..d2ef69783 100644 --- a/strands-ts/src/memory/__tests__/memory-manager.test.ts +++ b/strands-ts/src/memory/__tests__/memory-manager.test.ts @@ -364,7 +364,7 @@ describe('MemoryManager', () => { await expect(mm.add('fact', { stores: ['readonly'] })).rejects.toThrow("store 'readonly' is read-only") }) - it('fire-and-forget by default: resolves even when a store write fails', async () => { + it('awaits writes and throws AggregateError naming the failed store on partial failure', async () => { const failing: MemoryStore = { name: 'failing', writable: true, @@ -374,37 +374,19 @@ describe('MemoryManager', () => { const ok = createMockStore('ok', { writable: true }) const mm = new MemoryManager({ stores: [failing, ok] }) - // Default awaitWrites=false: dispatched to both, resolves without throwing. - await expect(mm.add('fact')).resolves.toBeUndefined() - expect(failing.add).toHaveBeenCalledWith('fact', undefined) - expect(ok.add).toHaveBeenCalledWith('fact', undefined) - }) - - it('awaitWrites: throws AggregateError naming the failed store on partial failure', async () => { - const failing: MemoryStore = { - name: 'failing', - writable: true, - search: vi.fn().mockResolvedValue([]), - add: vi.fn().mockRejectedValue(new Error('write error')), - } - const ok = createMockStore('ok', { writable: true }) - const mm = new MemoryManager({ stores: [failing, ok], awaitWrites: true }) - - // Partial failure (failing rejects, ok succeeds) throws when awaited. + // The method always awaits: a partial failure (failing rejects, ok succeeds) throws. await expect(mm.add('fact')).rejects.toThrow('store writes failed: failing') expect(ok.add).toHaveBeenCalledWith('fact', undefined) }) - it('awaitWrites per-call override forces awaiting on an otherwise fire-and-forget manager', async () => { - const failing: MemoryStore = { - name: 'failing', - writable: true, - search: vi.fn().mockResolvedValue([]), - add: vi.fn().mockRejectedValue(new Error('write error')), - } - const mm = new MemoryManager({ stores: [failing] }) + it('dispatches writes to all targeted stores', async () => { + const store1 = createMockStore('a', { writable: true }) + const store2 = createMockStore('b', { writable: true }) + const mm = new MemoryManager({ stores: [store1, store2] }) - await expect(mm.add('fact', { awaitWrites: true })).rejects.toThrow('store writes failed: failing') + await mm.add('fact') + expect(store1.add).toHaveBeenCalledWith('fact', undefined) + expect(store2.add).toHaveBeenCalledWith('fact', undefined) }) }) @@ -571,15 +553,15 @@ describe('MemoryManager', () => { expect(personal.add).not.toHaveBeenCalled() }) - it('add tool returns accepted count by default (fire-and-forget)', async () => { + it('add tool returns stored count by default (awaits writes)', async () => { const store = createMockStore('notes', { writable: true }) const mm = new MemoryManager({ stores: [store], addToolConfig: true }) const result = await addTool(mm).invoke({ entries: ['a', 'b'] }) - expect(result).toStrictEqual({ accepted: 2 }) + expect(result).toStrictEqual({ stored: 2 }) }) - it('add tool returns accepted even when a store write fails (fire-and-forget swallows it)', async () => { + it('add tool throws a flattened error with concrete reasons when entries fail (default awaits)', async () => { const failing: MemoryStore = { name: 'failing', writable: true, @@ -588,37 +570,37 @@ describe('MemoryManager', () => { } const mm = new MemoryManager({ stores: [failing], addToolConfig: true }) - const result = await addTool(mm).invoke({ entries: ['a', 'b'] }) - expect(result).toStrictEqual({ accepted: 2 }) + const error = await addTool(mm) + .invoke({ entries: ['a', 'b'] }) + .catch((e: unknown) => e) + expect(error).toBeInstanceOf(AggregateError) + const agg = error as AggregateError + expect(agg.message).toContain('failed to add 2 of 2 entries') + expect(agg.message).toContain('write error') + // Leaves are the underlying store errors, not the per-entry AggregateErrors add() throws. + expect(agg.errors).toHaveLength(2) + expect(agg.errors.every((e) => e instanceof Error && !(e instanceof AggregateError))).toBe(true) }) - it('add tool (awaitWrites) returns stored count when all entries succeed', async () => { + it('add tool with waitForWrites: false returns accepted count (fire-and-forget)', async () => { const store = createMockStore('notes', { writable: true }) - const mm = new MemoryManager({ stores: [store], addToolConfig: true, awaitWrites: true }) + const mm = new MemoryManager({ stores: [store], addToolConfig: { waitForWrites: false } }) const result = await addTool(mm).invoke({ entries: ['a', 'b'] }) - expect(result).toStrictEqual({ stored: 2 }) + expect(result).toStrictEqual({ accepted: 2 }) }) - it('add tool (awaitWrites) throws a flattened error with concrete reasons when entries fail', async () => { + it('add tool with waitForWrites: false returns accepted even when a store write fails (swallows it)', async () => { const failing: MemoryStore = { name: 'failing', writable: true, search: vi.fn().mockResolvedValue([]), add: vi.fn().mockRejectedValue(new Error('write error')), } - const mm = new MemoryManager({ stores: [failing], addToolConfig: true, awaitWrites: true }) + const mm = new MemoryManager({ stores: [failing], addToolConfig: { waitForWrites: false } }) - const error = await addTool(mm) - .invoke({ entries: ['a', 'b'] }) - .catch((e: unknown) => e) - expect(error).toBeInstanceOf(AggregateError) - const agg = error as AggregateError - expect(agg.message).toContain('failed to add 2 of 2 entries') - expect(agg.message).toContain('write error') - // Leaves are the underlying store errors, not the per-entry AggregateErrors add() throws. - expect(agg.errors).toHaveLength(2) - expect(agg.errors.every((e) => e instanceof Error && !(e instanceof AggregateError))).toBe(true) + const result = await addTool(mm).invoke({ entries: ['a', 'b'] }) + expect(result).toStrictEqual({ accepted: 2 }) }) }) diff --git a/strands-ts/src/memory/memory-manager.ts b/strands-ts/src/memory/memory-manager.ts index aac9292c2..f9709a464 100644 --- a/strands-ts/src/memory/memory-manager.ts +++ b/strands-ts/src/memory/memory-manager.ts @@ -62,11 +62,9 @@ export class MemoryManager implements Plugin { private readonly _searchStores: MemoryStore[] /** All writable stores — the unscoped target set for the programmatic {@link add} method. */ private readonly _addStores: MemoryStore[] - /** Writable stores the `add_memory` tool may write to (a subset of `_addStores`). */ - private readonly _addToolStores: MemoryStore[] private readonly _searchToolConfig: MemoryToolConfig | false - private readonly _addToolConfig: MemoryToolConfig | false - private readonly _awaitWrites: boolean + private readonly _addToolConfig: MemoryAddToolConfig | false + private readonly _addToolStores: MemoryStore[] constructor(config: MemoryManagerConfig) { if (config.stores.length === 0) { @@ -88,7 +86,6 @@ export class MemoryManager implements Plugin { this._config = config this._searchStores = config.stores this._addStores = config.stores.filter((s) => s.writable) - this._awaitWrites = config.awaitWrites ?? false this._searchToolConfig = config.searchToolConfig === false @@ -104,9 +101,8 @@ export class MemoryManager implements Plugin { if (this._addStores.length === 0) { throw new Error('MemoryManager: addToolConfig is enabled but no stores are writable') } - const toolConfig: MemoryAddToolConfig = typeof config.addToolConfig === 'object' ? config.addToolConfig : {} - this._addToolConfig = toolConfig - this._addToolStores = this._resolveAddToolStores(toolConfig) + this._addToolConfig = typeof config.addToolConfig === 'object' ? config.addToolConfig : {} + this._addToolStores = this._resolveAddToolStores(this._addToolConfig) } } @@ -159,7 +155,7 @@ export class MemoryManager implements Plugin { } if (this._addToolConfig !== false) { - tools.push(this._createAddTool(this._addToolConfig)) + tools.push(this._createAddTool(this._addToolConfig, this._addToolStores)) } for (const store of this._config.stores) { @@ -231,20 +227,16 @@ export class MemoryManager implements Plugin { } /** - * Add content to writable stores. If `stores` is provided, only writes to those named stores. + * Add content to writable stores. If `stores` is provided, only writes to those named stores; + * otherwise all writable stores are targeted. * - * This method is unscoped, with full access to all configured writable stores. - * Tool-level store scoping is applied by the add tool callback. - * When `options.stores` is omitted, all writable stores are targeted. - * - * Target stores are always validated synchronously (an unknown or read-only named store throws - * immediately). The store writes themselves follow `awaitWrites` (resolved from - * {@link MemoryAddOptions.awaitWrites} then {@link MemoryManagerConfig.awaitWrites}. - * - fire-and-forget (default): resolves once writes are dispatched; per-store failures are logged. - * - awaited: resolves after all writes settle and throws an `AggregateError` if any store fails. + * This method is unscoped, with full access to all configured writable stores; tool-level store + * scoping is applied by the add tool callback. Target stores are validated first (an unknown or + * read-only named store throws), then the writes are awaited: per-store failures are logged, and + * an `AggregateError` is thrown if any store fails. * * @param content - The text content to add - * @param options - Optional metadata, store name filter, and per-call `awaitWrites` override + * @param options - Optional metadata and store name filter */ async add(content: string, options?: MemoryAddOptions): Promise { let writableStores: MemoryStore[] @@ -268,33 +260,13 @@ export class MemoryManager implements Plugin { throw new Error('MemoryManager: no writable store matched') } - const write = this._writeToStores(writableStores, content, options?.metadata) - - if (options?.awaitWrites ?? this._awaitWrites) { - await write - } else { - // Fire-and-forget: failures are already logged inside _writeToStores; swallow the rejection - // here so the detached promise never surfaces as an unhandled rejection. - write.catch(() => {}) - } - } - - /** - * Writes content to every given store, logging per-store failures. Throws an `AggregateError` if - * any store fails. Callers decide whether to await (observe failures) or fire-and-forget. - */ - private async _writeToStores( - stores: MemoryStore[], - content: string, - metadata: Record | undefined - ): Promise { - const settled = await Promise.allSettled(stores.map((store) => store.add!(content, metadata))) + const settled = await Promise.allSettled(writableStores.map((store) => store.add!(content, options?.metadata))) const failures: { store: string; reason: unknown }[] = [] for (let i = 0; i < settled.length; i++) { const settledResult = settled[i]! if (settledResult.status === 'rejected') { - const storeName = stores[i]!.name + const storeName = writableStores[i]!.name logger.warn( `store=<${storeName}>, reason=<${normalizeError(settledResult.reason).message}> | store write failed` ) @@ -383,18 +355,17 @@ export class MemoryManager implements Plugin { }) } - private _createAddTool(config: MemoryToolConfig): Tool { + private _createAddTool(config: MemoryAddToolConfig, stores: MemoryStore[]): Tool { let description = config.description ?? ADD_TOOL_DESCRIPTION - const storeDescriptions = this._addToolStores - .filter((s) => s.description) - .map((s) => `- ${s.name}: ${s.description}`) + const storeDescriptions = stores.filter((s) => s.description).map((s) => `- ${s.name}: ${s.description}`) if (storeDescriptions.length > 0) { description += `\n\nAvailable writable stores:\n${storeDescriptions.join('\n')}` description += '\n\nYou can target a specific store by name to route facts to the right place, or omit to add to all available writable stores.' } - const scopedNames = this._addToolStores.map((s) => s.name) + const scopedNames = stores.map((s) => s.name) + const waitForWrites = config.waitForWrites ?? true const inputSchema = z.object({ entries: z.array(z.string()).min(1).describe('Data to add to long-term memory'), @@ -410,6 +381,17 @@ export class MemoryManager implements Plugin { inputSchema, callback: async (input) => { const stores = this._resolveToolTargets(scopedNames, input.stores) + + if (!waitForWrites) { + // Fire-and-forget: dispatch the writes without awaiting so the agent loop isn't blocked. + // add() logs per-store failures; swallow the rejection so it isn't an unhandled rejection. + for (const content of input.entries) { + void this.add(content, { stores }).catch(() => {}) + } + return { accepted: input.entries.length } as JSONValue + } + + // Await mode: surface failures to the model with concrete reasons (not nested AggregateErrors). const settled = await Promise.allSettled(input.entries.map((content) => this.add(content, { stores }))) const failures = settled.filter( (settledResult) => settledResult.status === 'rejected' @@ -423,7 +405,7 @@ export class MemoryManager implements Plugin { ) } - return (this._awaitWrites ? { stored: input.entries.length } : { accepted: input.entries.length }) as JSONValue + return { stored: input.entries.length } as JSONValue }, }) } diff --git a/strands-ts/src/memory/types.ts b/strands-ts/src/memory/types.ts index ada0930bd..081ad635a 100644 --- a/strands-ts/src/memory/types.ts +++ b/strands-ts/src/memory/types.ts @@ -106,15 +106,8 @@ export interface MemorySearchOptions extends SearchOptions { export interface MemoryAddOptions { /** Metadata to associate with the added entry. */ metadata?: Record - /** Filter to specific writable stores by name. Omit to write to all. */ + /** Filter to specific writable stores by name. Omit to write to all writable stores. */ stores?: string[] - /** - * Whether to await the store writes before resolving. Overrides the manager's - * {@link MemoryManagerConfig.awaitWrites} default for this call. - * - `false`: fire-and-forget — resolves once writes are dispatched; failures are logged. - * - `true`: awaits all writes and throws an `AggregateError` if any store fails. - */ - awaitWrites?: boolean } /** @@ -138,6 +131,14 @@ export interface MemoryAddToolConfig extends MemoryToolConfig { * Omit (or set `addToolConfig: true`) to allow all writable stores. */ stores?: (string | MemoryStore)[] + /** + * Whether the tool waits for store writes before returning to the model. Defaults to `true`. + * - `true` (default): waits for writes — the tool returns `{ stored }` on success, or surfaces a + * failure to the model if any store write fails. + * - `false`: fire-and-forget — the tool returns `{ accepted }` once writes are dispatched (so a + * slow backend never blocks the agent loop); per-store failures are logged. + */ + waitForWrites?: boolean } /** @@ -153,14 +154,4 @@ export interface MemoryManagerConfig { * writable stores; pass a {@link MemoryAddToolConfig} with `stores` to restrict it to specific ones. */ addToolConfig?: MemoryAddToolConfig | boolean - /** - * Whether write operations await each store before resolving. Defaults to `false`. - * - `false` (default): fire-and-forget. Writes are dispatched and the call resolves immediately so - * a slow backend never blocks the agent loop; per-store failures are logged, not thrown. - * - `true`: awaits all writes. Throws an `AggregateError` if any targeted store fails, so partial - * failures are observable. - * - * Per-call overridable via {@link MemoryAddOptions.awaitWrites}. - */ - awaitWrites?: boolean }