From 8fbe97da05c31eabfe5ceb45288e567fda533ba0 Mon Sep 17 00:00:00 2001 From: opieter-aws Date: Fri, 22 May 2026 15:32:03 -0400 Subject: [PATCH 1/2] feat: add memory primitive --- strands-ts/src/agent/agent.ts | 21 +- strands-ts/src/index.ts | 14 + .../memory/__tests__/memory-manager.test.ts | 256 ++++++++++++++++++ strands-ts/src/memory/index.ts | 12 + strands-ts/src/memory/memory-manager.ts | 241 +++++++++++++++++ strands-ts/src/memory/types.ts | 114 ++++++++ 6 files changed, 657 insertions(+), 1 deletion(-) 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 f7f268b6f..9558e6d65 100644 --- a/strands-ts/src/agent/agent.ts +++ b/strands-ts/src/agent/agent.ts @@ -68,6 +68,8 @@ import { AgentAsTool } from './agent-as-tool.js' import type { AgentAsToolOptions } from './agent-as-tool.js' import type { z } from 'zod' +import { MemoryManager } from '../memory/memory-manager.js' +import type { MemoryManagerConfig } from '../memory/types.js' import { SessionManager } from '../session/session-manager.js' import { Tracer } from '../telemetry/tracer.js' import { Meter } from '../telemetry/meter.js' @@ -186,9 +188,15 @@ export type AgentConfig = { */ structuredOutputSchema?: z.ZodSchema /** - * Session manager for saving and restoring agent sessions + * Session manager for saving and restoring agent sessions. */ sessionManager?: SessionManager + /** + * Memory manager for cross-session knowledge retrieval and storage. + * Manages one or more knowledge stores and exposes search/store 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. @@ -278,6 +286,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 knowledge retrieval and storage, if configured. + */ + public readonly memoryManager?: MemoryManager | undefined private readonly _hooksRegistry: HookRegistryImplementation private readonly _pluginRegistry: PluginRegistry @@ -311,6 +323,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 }) @@ -361,6 +379,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 18010b626..5530cfbf4 100644 --- a/strands-ts/src/index.ts +++ b/strands-ts/src/index.ts @@ -267,6 +267,20 @@ export type { Logger } from './logging/types.js' export { type McpClientConfig, type McpTransport, type TasksConfig, type McpConnectionState, McpClient } from './mcp.js' export type { ElicitationCallback, ElicitationContext } from './types/elicitation.js' +// Memory management +export { MemoryManager } from './memory/index.js' +export type { + KnowledgeEntry, + KnowledgeStore, + SearchOptions, + MemorySearchOptions, + MemoryStoreOptions, + StoreConfig, + MemoryToolConfig, + MemoryToolsConfig, + MemoryManagerConfig, +} from './memory/index.js' + // Session management export { SessionManager } from './session/session-manager.js' export type { 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..2ea8e6fe3 --- /dev/null +++ b/strands-ts/src/memory/__tests__/memory-manager.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi } from 'vitest' +import { Agent } from '../../agent/agent.js' +import { MemoryManager } from '../memory-manager.js' +import type { KnowledgeStore, KnowledgeEntry } from '../types.js' + +function createMockStore(entries: KnowledgeEntry[] = [], writable = false): KnowledgeStore { + const store: KnowledgeStore = { + search: vi.fn().mockResolvedValue(entries), + } + if (writable) { + store.add = vi.fn().mockResolvedValue(undefined) + } + return store +} + +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: [{ store: createMockStore() }] }) + expect(mm.name).toBe('strands:memory-manager') + }) + }) + + describe('getTools', () => { + it('registers search tool by default', () => { + const mm = new MemoryManager({ stores: [{ store: createMockStore() }] }) + const tools = mm.getTools() + expect(tools).toHaveLength(1) + expect(tools[0]!.name).toBe('search_memory') + }) + + it('registers both search and store tools when a writable store exists', () => { + const mm = new MemoryManager({ stores: [{ store: createMockStore([], true) }] }) + const tools = mm.getTools() + expect(tools).toHaveLength(2) + expect(tools.map((t) => t.name)).toStrictEqual(['search_memory', 'store_memory']) + }) + + it('does not register store tool when no writable stores exist', () => { + const mm = new MemoryManager({ stores: [{ store: createMockStore() }] }) + const tools = mm.getTools() + expect(tools.map((t) => t.name)).toStrictEqual(['search_memory']) + }) + + it('returns empty array when includeTools is false', () => { + const mm = new MemoryManager({ stores: [{ store: createMockStore([], true) }], includeTools: false }) + expect(mm.getTools()).toStrictEqual([]) + }) + + it('respects ToolsConfig to disable search', () => { + const mm = new MemoryManager({ + stores: [{ store: createMockStore([], true) }], + includeTools: { search: false }, + }) + const tools = mm.getTools() + expect(tools.map((t) => t.name)).toStrictEqual(['store_memory']) + }) + + it('respects ToolsConfig to disable store', () => { + const mm = new MemoryManager({ + stores: [{ store: createMockStore([], true) }], + includeTools: { store: false }, + }) + const tools = mm.getTools() + expect(tools.map((t) => t.name)).toStrictEqual(['search_memory']) + }) + + it('uses custom tool names from MemoryToolsConfig', () => { + const mm = new MemoryManager({ + stores: [{ store: createMockStore([], true) }], + includeTools: { search: { name: 'recall' }, store: { name: 'remember' } }, + }) + const tools = mm.getTools() + expect(tools.map((t) => t.name)).toStrictEqual(['recall', 'remember']) + }) + + it('includes store descriptions in search tool description when stores are named', () => { + const mm = new MemoryManager({ + stores: [{ store: createMockStore(), name: 'personal', description: 'User preferences' }], + }) + 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 store tool description when writable stores are named', () => { + const mm = new MemoryManager({ + stores: [{ store: createMockStore([], true), name: 'notes', description: 'Personal notes' }], + }) + const tools = mm.getTools() + const storeTool = tools.find((t) => t.name === 'store_memory')! + expect(storeTool.description).toContain('notes: Personal notes') + expect(storeTool.description).toContain('target a specific store by name') + }) + }) + + describe('search', () => { + it('queries all stores and concatenates results', async () => { + const store1 = createMockStore([{ id: '1', content: 'fact one' }]) + const store2 = createMockStore([{ id: '2', content: 'fact two' }]) + const mm = new MemoryManager({ stores: [{ store: store1 }, { store: store2 }] }) + + const results = await mm.search('query') + expect(results).toStrictEqual([ + { id: '1', content: 'fact one' }, + { id: '2', content: 'fact two' }, + ]) + }) + + it('passes limit to each store', async () => { + const store = createMockStore() + const mm = new MemoryManager({ stores: [{ store, limit: 5 }] }) + + await mm.search('query') + expect(store.search).toHaveBeenCalledWith('query', { limit: 5 }) + }) + + it('overrides per-store limit with options.limit', async () => { + const store = createMockStore() + const mm = new MemoryManager({ stores: [{ store, limit: 5 }] }) + + await mm.search('query', { limit: 3 }) + expect(store.search).toHaveBeenCalledWith('query', { limit: 3 }) + }) + + it('defaults to limit of 10 when no limit configured', async () => { + const store = createMockStore() + const mm = new MemoryManager({ stores: [{ store }] }) + + await mm.search('query') + expect(store.search).toHaveBeenCalledWith('query', { limit: 10 }) + }) + + it('filters to named stores when options.stores is provided', async () => { + const store1 = createMockStore([{ id: '1', content: 'personal fact' }]) + const store2 = createMockStore([{ id: '2', content: 'team fact' }]) + const mm = new MemoryManager({ + stores: [ + { store: store1, name: 'personal' }, + { store: store2, name: 'team' }, + ], + }) + + const results = await mm.search('query', { stores: ['personal'] }) + expect(results).toStrictEqual([{ id: '1', content: 'personal fact' }]) + expect(store2.search).not.toHaveBeenCalled() + }) + + it('gracefully handles store failures', async () => { + const store1: KnowledgeStore = { search: vi.fn().mockRejectedValue(new Error('network error')) } + const store2 = createMockStore([{ id: '2', content: 'fact' }]) + const mm = new MemoryManager({ stores: [{ store: store1 }, { store: store2 }] }) + + const results = await mm.search('query') + expect(results).toStrictEqual([{ id: '2', content: 'fact' }]) + }) + }) + + describe('store', () => { + it('writes to all writable stores', async () => { + const store1 = createMockStore([], true) + const store2 = createMockStore([], true) + const mm = new MemoryManager({ stores: [{ store: store1 }, { store: store2 }] }) + + await mm.store('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([], true) + const mm = new MemoryManager({ stores: [{ store }] }) + + await mm.store('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([], true) + const store2 = createMockStore([], true) + const mm = new MemoryManager({ + stores: [ + { store: store1, name: 'personal' }, + { store: store2, name: 'team' }, + ], + }) + + await mm.store('my preference', { stores: ['personal'] }) + expect(store1.add).toHaveBeenCalledWith('my preference', undefined) + expect(store2.add).not.toHaveBeenCalled() + }) + + it('throws when no writable stores are configured', async () => { + const mm = new MemoryManager({ stores: [{ store: createMockStore() }] }) + await expect(mm.store('fact')).rejects.toThrow('no writable store configured') + }) + + it('throws when named stores filter matches no writable stores', async () => { + const store = createMockStore([], true) + const mm = new MemoryManager({ stores: [{ store, name: 'personal' }] }) + await expect(mm.store('fact', { stores: ['nonexistent'] })).rejects.toThrow('no writable store configured') + }) + + it('succeeds with partial write failures (some stores fail, some succeed)', async () => { + const store1: KnowledgeStore = { + search: vi.fn().mockResolvedValue([]), + add: vi.fn().mockRejectedValue(new Error('write error')), + } + const store2 = createMockStore([], true) + const mm = new MemoryManager({ stores: [{ store: store1 }, { store: store2 }] }) + + await mm.store('fact') + expect(store2.add).toHaveBeenCalledWith('fact', undefined) + }) + + it('throws AggregateError when all writes fail', async () => { + const store: KnowledgeStore = { + search: vi.fn().mockResolvedValue([]), + add: vi.fn().mockRejectedValue(new Error('write error')), + } + const mm = new MemoryManager({ stores: [{ store }] }) + + await expect(mm.store('fact')).rejects.toThrow('all store writes failed') + }) + }) + + describe('initAgent', () => { + it('does not throw', () => { + const mm = new MemoryManager({ stores: [{ store: createMockStore() }] }) + expect(() => mm.initAgent({} as any)).not.toThrow() + }) + }) + + describe('AgentConfig integration', () => { + it('auto-wraps MemoryManagerConfig into MemoryManager instance', () => { + const store = createMockStore() + const agent = new Agent({ memoryManager: { stores: [{ store }] } }) + expect(agent.memoryManager).toBeInstanceOf(MemoryManager) + }) + + it('passes through MemoryManager instance unchanged', () => { + const mm = new MemoryManager({ stores: [{ store: createMockStore() }] }) + 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..e1a1acdf3 --- /dev/null +++ b/strands-ts/src/memory/index.ts @@ -0,0 +1,12 @@ +export { MemoryManager } from './memory-manager.js' +export type { + KnowledgeEntry, + KnowledgeStore, + SearchOptions, + MemorySearchOptions, + MemoryStoreOptions, + StoreConfig, + MemoryToolConfig, + MemoryToolsConfig, + 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..20595bb2e --- /dev/null +++ b/strands-ts/src/memory/memory-manager.ts @@ -0,0 +1,241 @@ +import type { Plugin } from '../plugins/plugin.js' +import type { LocalAgent } from '../types/agent.js' +import type { Tool } from '../tools/tool.js' +import type { + KnowledgeEntry, + MemoryManagerConfig, + MemorySearchOptions, + MemoryStoreOptions, + MemoryToolsConfig, +} 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' + +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 STORE_TOOL_DESCRIPTION = + 'Store facts, preferences, or decisions that should be remembered across conversations. Use when the user shares something worth recalling later.' + +/** + * Provides cross-session knowledge retrieval and storage for agents. + * + * Manages one or more {@link KnowledgeStore} backends, exposing `search_memory` and + * `store_memory` tools for agent-driven recall and persistence. + * + * @example + * ```typescript + * import { Agent, MemoryManager } from '@strands-agents/sdk' + * + * // Config shorthand + * const agent = new Agent({ + * model, + * memoryManager: { stores: [{ store: myKnowledgeBase }] }, + * }) + * + * // Class instance (for programmatic access) + * const memoryManager = new MemoryManager({ stores: [{ store: myKnowledgeBase }] }) + * 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 _toolsConfig: MemoryToolsConfig | false + + constructor(config: MemoryManagerConfig) { + if (config.stores.length === 0) { + throw new Error('MemoryManager: at least one store is required') + } + + this._config = config + + this._toolsConfig = + config.includeTools === false ? false : typeof config.includeTools === 'object' ? config.includeTools : {} + } + + /** + * Registers lifecycle hooks with the agent. + * + * @param _agent - The agent to register hooks with + */ + initAgent(_agent: LocalAgent): void {} + + /** + * Returns tools registered by this plugin (controlled by `includeTools` config). + * + * @returns Array of tools to register with the agent + */ + getTools(): Tool[] { + if (this._toolsConfig === false) return [] + + const tools: Tool[] = [] + + const searchConfig = this._toolsConfig.search + if (searchConfig !== false) { + tools.push(this._createSearchTool(typeof searchConfig === 'object' ? searchConfig : undefined)) + } + + const storeConfig = this._toolsConfig.store + if (storeConfig !== false && this._hasWritableStore()) { + tools.push(this._createStoreTool(typeof storeConfig === 'object' ? storeConfig : undefined)) + } + + return tools + } + + /** + * Search configured stores for entries matching the query. + * + * Each store receives the `limit` individually — results are concatenated in store config order. + * Stores that fail are logged and skipped. + * + * @param query - The search query string + * @param options - Optional limit per-store and store name filter + * @returns Array of knowledge entries from matching stores + */ + async search(query: string, options?: MemorySearchOptions): Promise { + logger.debug(`query=<${query}>, limit=<${options?.limit}>, stores=<${options?.stores}> | searching stores`) + + const targetStores = options?.stores?.length + ? this._config.stores.filter((s) => s.name && options.stores!.includes(s.name)) + : this._config.stores + + if (options?.stores?.length && targetStores.length === 0) { + logger.warn(`stores=<${options.stores.join(', ')}> | no stores matched filter`) + } + + const limit = options?.limit + const settled = await Promise.allSettled( + targetStores.map((config) => config.store.search(query, { limit: limit ?? config.limit ?? 10 })) + ) + + const results: KnowledgeEntry[] = [] + for (let i = 0; i < settled.length; i++) { + const r = settled[i]! + if (r.status === 'rejected') { + logger.warn(`store=<${targetStores[i]!.name ?? i}>, reason=<${r.reason}> | store search failed`) + continue + } + for (const entry of r.value) { + results.push(entry) + } + } + + logger.debug(`results=<${results.length}> | search complete`) + return results + } + + /** + * Store content in writable stores. If `stores` is provided, only writes to those named stores. + * + * Partial failures are logged. If all writes fail, throws an `AggregateError`. + * + * @param content - The text content to store + * @param options - Optional metadata and store name filter + */ + async store(content: string, options?: MemoryStoreOptions): Promise { + let writableStores = this._config.stores.filter((s) => s.store.add) + + if (options?.stores?.length) { + writableStores = writableStores.filter((s) => s.name && options.stores!.includes(s.name)) + } + + if (writableStores.length === 0) { + throw new Error('MemoryManager: no writable store configured') + } + + const settled = await Promise.allSettled(writableStores.map((s) => s.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 storeName = writableStores[i]!.name ?? String(i) + logger.warn(`store=<${storeName}>, reason=<${r.reason}> | 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' + ) + } + } + + private _hasWritableStore(): boolean { + return this._config.stores.some((s) => s.store.add) + } + + private _createSearchTool(config?: { name?: string; description?: string }): Tool { + let description = config?.description ?? SEARCH_TOOL_DESCRIPTION + const storeDescriptions = this._config.stores + .filter((s) => s.name || s.description) + .map((s) => `- ${s.name ?? 'unnamed'}${s.description ? `: ${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 inputSchema = z.object({ + query: z.string().describe('What to search for'), + limit: z.number().optional().describe('Maximum number of results per store'), + stores: z.array(z.string()).optional().describe('Filter to specific stores by name, or omit to search all'), + }) + + return tool({ + name: config?.name ?? 'search_memory', + description, + inputSchema, + callback: async (input) => { + const results = await this.search(input.query, { + ...(input.limit != null && { limit: input.limit }), + ...(input.stores != null && { stores: input.stores }), + }) + return results.map((entry) => ({ + id: entry.id, + content: entry.content, + ...(entry.metadata && { metadata: entry.metadata }), + })) as JSONValue + }, + }) + } + + private _createStoreTool(config?: { name?: string; description?: string }): Tool { + const writableStores = this._config.stores.filter((s) => s.store.add) + + let description = config?.description ?? STORE_TOOL_DESCRIPTION + const storeDescriptions = writableStores + .filter((s) => s.name || s.description) + .map((s) => `- ${s.name ?? 'unnamed'}${s.description ? `: ${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 store in all writable stores.' + } + + const inputSchema = z.object({ + entries: z.array(z.string()).describe('Data to store in long-term memory'), + stores: z.array(z.string()).optional().describe('Target specific stores by name, or omit to store in all'), + }) + + return tool({ + name: config?.name ?? 'store_memory', + description, + inputSchema, + callback: async (input) => { + const settled = await Promise.allSettled( + input.entries.map((content) => this.store(content, input.stores ? { stores: input.stores } : undefined)) + ) + const stored = settled.filter((r) => r.status === 'fulfilled').length + const failed = settled.filter((r) => r.status === 'rejected').length + return { stored, failed } as JSONValue + }, + }) + } +} diff --git a/strands-ts/src/memory/types.ts b/strands-ts/src/memory/types.ts new file mode 100644 index 000000000..dda178a93 --- /dev/null +++ b/strands-ts/src/memory/types.ts @@ -0,0 +1,114 @@ +/** + * A single entry retrieved from or stored to a knowledge store. + */ +export interface KnowledgeEntry { + /** Unique identifier for this entry, assigned by the store. */ + id: string + /** The textual content of this knowledge entry. */ + content: string + /** Optional metadata (e.g., score, source, timestamp). */ + metadata?: Record +} + +/** + * Options passed to {@link KnowledgeStore.search}. + * Store implementations may extend this with additional fields in their own signatures. + */ +export interface SearchOptions { + /** Maximum number of results to return. */ + limit?: number +} + +/** + * Interface for a knowledge store backend. + * + * Only `search` is required. Stores that support mutation may additionally implement `add`. + * + * @example + * ```typescript + * const store: KnowledgeStore = { + * async search(query, options) { + * return myVectorDb.query(query, { topK: options?.limit ?? 10 }) + * } + * } + * ``` + */ +export interface KnowledgeStore { + /** Search the store for entries matching the query, ordered by relevance. */ + search(query: string, options?: SearchOptions): Promise + /** Add content to the store. Optional — only present on mutable stores. */ + add?(content: string, metadata?: Record): Promise +} + +/** + * Options for {@link MemoryManager.search}. + */ +export interface MemorySearchOptions { + /** Maximum number of results per store. */ + limit?: number + /** Filter to specific stores by name. Omit to search all. */ + stores?: string[] +} + +/** + * Options for {@link MemoryManager.store}. + */ +export interface MemoryStoreOptions { + /** Metadata to associate with the stored entry. */ + metadata?: Record + /** Filter to specific writable stores by name. Omit to write to all. */ + stores?: string[] +} + +/** + * Configuration for a single knowledge store within the memory manager. + */ +export interface StoreConfig { + /** The knowledge store instance. */ + store: KnowledgeStore + /** Identifier for this store, used to target specific stores in search. */ + name?: string + /** Human-readable description of what this store contains. */ + description?: string + /** Default max results per query for this store. Defaults to 10. */ + limit?: number +} + +/** + * Configuration for customizing a memory tool's name or description. + */ +export interface MemoryToolConfig { + /** Custom tool name. */ + name?: string + /** Custom tool description. */ + description?: string +} + +/** + * Configuration for which tools the memory manager exposes to the agent. + */ +export interface MemoryToolsConfig { + /** Configuration for the search tool. `false` disables it. */ + search?: boolean | MemoryToolConfig + /** Configuration for the store tool. `false` disables it. */ + store?: boolean | MemoryToolConfig +} + +/** + * Configuration for the {@link MemoryManager}. + * + * @example + * ```typescript + * // Config shorthand (auto-wrapped into MemoryManager) + * const agent = new Agent({ + * model, + * memoryManager: { stores: [{ store: myKnowledgeBase }] }, + * }) + * ``` + */ +export interface MemoryManagerConfig { + /** One or more knowledge stores to query. */ + stores: StoreConfig[] + /** Whether to register tools for agent-driven search and storage. Defaults to `true`. */ + includeTools?: boolean | MemoryToolsConfig +} From 4a3b024479bda936f4ffe65dff711d15bd9bb665 Mon Sep 17 00:00:00 2001 From: opieter-aws Date: Thu, 28 May 2026 15:31:15 -0400 Subject: [PATCH 2/2] Update interfaces --- strands-ts/src/agent/agent.ts | 2 +- strands-ts/src/index.ts | 6 +- .../memory/__tests__/memory-manager.test.ts | 222 ++++++++++-------- strands-ts/src/memory/index.ts | 6 +- strands-ts/src/memory/memory-manager.ts | 127 ++++++---- strands-ts/src/memory/types.ts | 88 +++---- 6 files changed, 233 insertions(+), 218 deletions(-) diff --git a/strands-ts/src/agent/agent.ts b/strands-ts/src/agent/agent.ts index 9558e6d65..b4072032c 100644 --- a/strands-ts/src/agent/agent.ts +++ b/strands-ts/src/agent/agent.ts @@ -69,7 +69,7 @@ import type { AgentAsToolOptions } from './agent-as-tool.js' import type { z } from 'zod' import { MemoryManager } from '../memory/memory-manager.js' -import type { MemoryManagerConfig } from '../memory/types.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' diff --git a/strands-ts/src/index.ts b/strands-ts/src/index.ts index 5530cfbf4..20c8415f2 100644 --- a/strands-ts/src/index.ts +++ b/strands-ts/src/index.ts @@ -270,14 +270,12 @@ export type { ElicitationCallback, ElicitationContext } from './types/elicitatio // Memory management export { MemoryManager } from './memory/index.js' export type { - KnowledgeEntry, - KnowledgeStore, + MemoryEntry, + MemoryStore, SearchOptions, MemorySearchOptions, MemoryStoreOptions, - StoreConfig, MemoryToolConfig, - MemoryToolsConfig, 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 2ea8e6fe3..58b2e4630 100644 --- a/strands-ts/src/memory/__tests__/memory-manager.test.ts +++ b/strands-ts/src/memory/__tests__/memory-manager.test.ts @@ -1,13 +1,19 @@ import { describe, it, expect, vi } from 'vitest' import { Agent } from '../../agent/agent.js' import { MemoryManager } from '../memory-manager.js' -import type { KnowledgeStore, KnowledgeEntry } from '../types.js' - -function createMockStore(entries: KnowledgeEntry[] = [], writable = false): KnowledgeStore { - const store: KnowledgeStore = { - search: vi.fn().mockResolvedValue(entries), +import type { MemoryStore, MemoryEntry } from '../types.js' + +function createMockStore( + name: string, + options?: { entries?: MemoryEntry[]; writable?: boolean; description?: string; limit?: number } +): MemoryStore { + const store: MemoryStore = { + name, + ...(options?.description && { description: options.description }), + ...(options?.limit != null && { limit: options.limit }), + search: vi.fn().mockResolvedValue(options?.entries ?? []), } - if (writable) { + if (options?.writable) { store.add = vi.fn().mockResolvedValue(undefined) } return store @@ -20,77 +26,102 @@ describe('MemoryManager', () => { }) it('creates instance with valid config', () => { - const mm = new MemoryManager({ stores: [{ store: createMockStore() }] }) + const mm = new MemoryManager({ stores: [createMockStore('test')] }) expect(mm.name).toBe('strands:memory-manager') }) + + it('throws when storeToolConfig references non-existent store', () => { + expect( + () => + new MemoryManager({ + stores: [createMockStore('a')], + storeToolConfig: { stores: ['nonexistent'] }, + }) + ).toThrow("store 'nonexistent' not found") + }) + + it('throws when storeToolConfig targets no writable stores', () => { + expect( + () => + new MemoryManager({ + stores: [createMockStore('a')], + storeToolConfig: true, + }) + ).toThrow('storeToolConfig targets no writable stores') + }) + + it('throws when storeToolConfig is true with multiple writable stores and no explicit stores', () => { + expect( + () => + new MemoryManager({ + stores: [createMockStore('a', { writable: true }), createMockStore('b', { writable: true })], + storeToolConfig: true, + }) + ).toThrow('must specify `stores` when multiple writable stores are configured') + }) + + it('allows storeToolConfig true with single writable store', () => { + const mm = new MemoryManager({ + stores: [createMockStore('a', { writable: true })], + storeToolConfig: true, + }) + expect(mm.getTools().map((t) => t.name)).toContain('store_memory') + }) }) describe('getTools', () => { it('registers search tool by default', () => { - const mm = new MemoryManager({ stores: [{ store: createMockStore() }] }) + const mm = new MemoryManager({ stores: [createMockStore('test')] }) const tools = mm.getTools() expect(tools).toHaveLength(1) expect(tools[0]!.name).toBe('search_memory') }) - it('registers both search and store tools when a writable store exists', () => { - const mm = new MemoryManager({ stores: [{ store: createMockStore([], true) }] }) + it('registers store tool when storeToolConfig is enabled', () => { + const mm = new MemoryManager({ + stores: [createMockStore('test', { writable: true })], + storeToolConfig: true, + }) const tools = mm.getTools() - expect(tools).toHaveLength(2) expect(tools.map((t) => t.name)).toStrictEqual(['search_memory', 'store_memory']) }) - it('does not register store tool when no writable stores exist', () => { - const mm = new MemoryManager({ stores: [{ store: createMockStore() }] }) + it('does not register store 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 includeTools is false', () => { - const mm = new MemoryManager({ stores: [{ store: createMockStore([], true) }], includeTools: false }) - expect(mm.getTools()).toStrictEqual([]) - }) - - it('respects ToolsConfig to disable search', () => { - const mm = new MemoryManager({ - stores: [{ store: createMockStore([], true) }], - includeTools: { search: false }, - }) - const tools = mm.getTools() - expect(tools.map((t) => t.name)).toStrictEqual(['store_memory']) - }) - - it('respects ToolsConfig to disable store', () => { + it('returns empty array when searchToolConfig is false and storeToolConfig is false', () => { const mm = new MemoryManager({ - stores: [{ store: createMockStore([], true) }], - includeTools: { store: false }, + stores: [createMockStore('test', { writable: true })], + searchToolConfig: false, + storeToolConfig: false, }) - const tools = mm.getTools() - expect(tools.map((t) => t.name)).toStrictEqual(['search_memory']) + expect(mm.getTools()).toStrictEqual([]) }) - it('uses custom tool names from MemoryToolsConfig', () => { + it('uses custom tool names from MemoryToolConfig', () => { const mm = new MemoryManager({ - stores: [{ store: createMockStore([], true) }], - includeTools: { search: { name: 'recall' }, store: { name: 'remember' } }, + stores: [createMockStore('test', { writable: true })], + searchToolConfig: { name: 'recall' }, + storeToolConfig: { name: 'remember', stores: ['test'] }, }) const tools = mm.getTools() expect(tools.map((t) => t.name)).toStrictEqual(['recall', 'remember']) }) - it('includes store descriptions in search tool description when stores are named', () => { - const mm = new MemoryManager({ - stores: [{ store: createMockStore(), name: 'personal', description: 'User preferences' }], - }) + 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 store tool description when writable stores are named', () => { - const mm = new MemoryManager({ - stores: [{ store: createMockStore([], true), name: 'notes', description: 'Personal notes' }], - }) + it('includes store descriptions in store tool description', () => { + const store = createMockStore('notes', { writable: true, description: 'Personal notes' }) + const mm = new MemoryManager({ stores: [store], storeToolConfig: true }) const tools = mm.getTools() const storeTool = tools.find((t) => t.name === 'store_memory')! expect(storeTool.description).toContain('notes: Personal notes') @@ -100,71 +131,63 @@ describe('MemoryManager', () => { describe('search', () => { it('queries all stores and concatenates results', async () => { - const store1 = createMockStore([{ id: '1', content: 'fact one' }]) - const store2 = createMockStore([{ id: '2', content: 'fact two' }]) - const mm = new MemoryManager({ stores: [{ store: store1 }, { store: store2 }] }) + 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([ - { id: '1', content: 'fact one' }, - { id: '2', content: 'fact two' }, - ]) + expect(results).toStrictEqual([{ content: 'fact one' }, { content: 'fact two' }]) }) it('passes limit to each store', async () => { - const store = createMockStore() - const mm = new MemoryManager({ stores: [{ store, limit: 5 }] }) + const store = createMockStore('a', { limit: 5 }) + const mm = new MemoryManager({ stores: [store] }) await mm.search('query') expect(store.search).toHaveBeenCalledWith('query', { limit: 5 }) }) it('overrides per-store limit with options.limit', async () => { - const store = createMockStore() - const mm = new MemoryManager({ stores: [{ store, limit: 5 }] }) + const store = createMockStore('a', { limit: 5 }) + const mm = new MemoryManager({ stores: [store] }) - await mm.search('query', { limit: 3 }) - expect(store.search).toHaveBeenCalledWith('query', { limit: 3 }) + await mm.search('query', { limit: 2 }) + expect(store.search).toHaveBeenCalledWith('query', { limit: 2 }) }) - it('defaults to limit of 10 when no limit configured', async () => { - const store = createMockStore() - const mm = new MemoryManager({ stores: [{ store }] }) + it('defaults to limit of 3 when no limit configured', async () => { + const store = createMockStore('a') + const mm = new MemoryManager({ stores: [store] }) await mm.search('query') - expect(store.search).toHaveBeenCalledWith('query', { limit: 10 }) + expect(store.search).toHaveBeenCalledWith('query', { limit: 3 }) }) it('filters to named stores when options.stores is provided', async () => { - const store1 = createMockStore([{ id: '1', content: 'personal fact' }]) - const store2 = createMockStore([{ id: '2', content: 'team fact' }]) - const mm = new MemoryManager({ - stores: [ - { store: store1, name: 'personal' }, - { store: store2, name: 'team' }, - ], - }) + 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([{ id: '1', content: 'personal fact' }]) + expect(results).toStrictEqual([{ content: 'personal fact' }]) expect(store2.search).not.toHaveBeenCalled() }) it('gracefully handles store failures', async () => { - const store1: KnowledgeStore = { search: vi.fn().mockRejectedValue(new Error('network error')) } - const store2 = createMockStore([{ id: '2', content: 'fact' }]) - const mm = new MemoryManager({ stores: [{ store: store1 }, { store: store2 }] }) + const store1: MemoryStore = { name: 'failing', 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([{ id: '2', content: 'fact' }]) + expect(results).toStrictEqual([{ content: 'fact' }]) }) }) describe('store', () => { it('writes to all writable stores', async () => { - const store1 = createMockStore([], true) - const store2 = createMockStore([], true) - const mm = new MemoryManager({ stores: [{ store: store1 }, { store: store2 }] }) + const store1 = createMockStore('a', { writable: true }) + const store2 = createMockStore('b', { writable: true }) + const mm = new MemoryManager({ stores: [store1, store2] }) await mm.store('user likes coffee') expect(store1.add).toHaveBeenCalledWith('user likes coffee', undefined) @@ -172,57 +195,48 @@ describe('MemoryManager', () => { }) it('passes metadata to stores', async () => { - const store = createMockStore([], true) - const mm = new MemoryManager({ stores: [{ store }] }) + const store = createMockStore('a', { writable: true }) + const mm = new MemoryManager({ stores: [store] }) await mm.store('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([], true) - const store2 = createMockStore([], true) - const mm = new MemoryManager({ - stores: [ - { store: store1, name: 'personal' }, - { store: store2, name: 'team' }, - ], - }) + const store1 = createMockStore('personal', { writable: true }) + const store2 = createMockStore('team', { writable: true }) + const mm = new MemoryManager({ stores: [store1, store2] }) await mm.store('my preference', { stores: ['personal'] }) expect(store1.add).toHaveBeenCalledWith('my preference', undefined) expect(store2.add).not.toHaveBeenCalled() }) - it('throws when no writable stores are configured', async () => { - const mm = new MemoryManager({ stores: [{ store: createMockStore() }] }) - await expect(mm.store('fact')).rejects.toThrow('no writable store configured') - }) - - it('throws when named stores filter matches no writable stores', async () => { - const store = createMockStore([], true) - const mm = new MemoryManager({ stores: [{ store, name: 'personal' }] }) - await expect(mm.store('fact', { stores: ['nonexistent'] })).rejects.toThrow('no writable store configured') + it('throws when no writable stores match', async () => { + const mm = new MemoryManager({ stores: [createMockStore('a')] }) + await expect(mm.store('fact')).rejects.toThrow('no writable store matched') }) it('succeeds with partial write failures (some stores fail, some succeed)', async () => { - const store1: KnowledgeStore = { + const store1: MemoryStore = { + name: 'failing', search: vi.fn().mockResolvedValue([]), add: vi.fn().mockRejectedValue(new Error('write error')), } - const store2 = createMockStore([], true) - const mm = new MemoryManager({ stores: [{ store: store1 }, { store: store2 }] }) + const store2 = createMockStore('ok', { writable: true }) + const mm = new MemoryManager({ stores: [store1, store2] }) await mm.store('fact') expect(store2.add).toHaveBeenCalledWith('fact', undefined) }) it('throws AggregateError when all writes fail', async () => { - const store: KnowledgeStore = { + const store: MemoryStore = { + name: 'failing', search: vi.fn().mockResolvedValue([]), add: vi.fn().mockRejectedValue(new Error('write error')), } - const mm = new MemoryManager({ stores: [{ store }] }) + const mm = new MemoryManager({ stores: [store] }) await expect(mm.store('fact')).rejects.toThrow('all store writes failed') }) @@ -230,20 +244,20 @@ describe('MemoryManager', () => { describe('initAgent', () => { it('does not throw', () => { - const mm = new MemoryManager({ stores: [{ store: createMockStore() }] }) + 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() - const agent = new Agent({ memoryManager: { stores: [{ store }] } }) + 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: [{ store: createMockStore() }] }) + const mm = new MemoryManager({ stores: [createMockStore('test')] }) const agent = new Agent({ memoryManager: mm }) expect(agent.memoryManager).toBe(mm) }) diff --git a/strands-ts/src/memory/index.ts b/strands-ts/src/memory/index.ts index e1a1acdf3..1338c7811 100644 --- a/strands-ts/src/memory/index.ts +++ b/strands-ts/src/memory/index.ts @@ -1,12 +1,10 @@ export { MemoryManager } from './memory-manager.js' export type { - KnowledgeEntry, - KnowledgeStore, + MemoryEntry, + MemoryStore, SearchOptions, MemorySearchOptions, MemoryStoreOptions, - StoreConfig, MemoryToolConfig, - MemoryToolsConfig, MemoryManagerConfig, } from './types.js' diff --git a/strands-ts/src/memory/memory-manager.ts b/strands-ts/src/memory/memory-manager.ts index 20595bb2e..62d3458da 100644 --- a/strands-ts/src/memory/memory-manager.ts +++ b/strands-ts/src/memory/memory-manager.ts @@ -2,11 +2,12 @@ import type { Plugin } from '../plugins/plugin.js' import type { LocalAgent } from '../types/agent.js' import type { Tool } from '../tools/tool.js' import type { - KnowledgeEntry, + MemoryEntry, MemoryManagerConfig, MemorySearchOptions, + MemoryStore, MemoryStoreOptions, - MemoryToolsConfig, + MemoryToolConfig, } from './types.js' import type { JSONValue } from '../types/json.js' import { tool } from '../tools/tool-factory.js' @@ -19,10 +20,12 @@ const SEARCH_TOOL_DESCRIPTION = const STORE_TOOL_DESCRIPTION = 'Store facts, preferences, or decisions that should be remembered across conversations. Use when the user shares something worth recalling later.' +const DEFAULT_RESULTS_PER_STORE = 3 + /** * Provides cross-session knowledge retrieval and storage for agents. * - * Manages one or more {@link KnowledgeStore} backends, exposing `search_memory` and + * Manages one or more {@link MemoryStore} backends, exposing `search_memory` and * `store_memory` tools for agent-driven recall and persistence. * * @example @@ -32,11 +35,11 @@ const STORE_TOOL_DESCRIPTION = * // Config shorthand * const agent = new Agent({ * model, - * memoryManager: { stores: [{ store: myKnowledgeBase }] }, + * memoryManager: { stores: [myStore], storeToolConfig: true }, * }) * * // Class instance (for programmatic access) - * const memoryManager = new MemoryManager({ stores: [{ store: myKnowledgeBase }] }) + * const memoryManager = new MemoryManager({ stores: [myStore], storeToolConfig: true }) * const agent = new Agent({ model, memoryManager }) * await memoryManager.search('user preferences') * ``` @@ -44,7 +47,10 @@ const STORE_TOOL_DESCRIPTION = export class MemoryManager implements Plugin { readonly name = 'strands:memory-manager' private readonly _config: MemoryManagerConfig - private readonly _toolsConfig: MemoryToolsConfig | false + private readonly _searchStores: MemoryStore[] + private readonly _storeStores: MemoryStore[] + private readonly _searchToolConfig: MemoryToolConfig | false + private readonly _storeToolConfig: MemoryToolConfig | false constructor(config: MemoryManagerConfig) { if (config.stores.length === 0) { @@ -53,8 +59,35 @@ export class MemoryManager implements Plugin { this._config = config - this._toolsConfig = - config.includeTools === false ? false : typeof config.includeTools === 'object' ? config.includeTools : {} + if (config.searchToolConfig === false) { + this._searchToolConfig = false + this._searchStores = [] + } else { + const toolConfig = typeof config.searchToolConfig === 'object' ? config.searchToolConfig : {} + this._searchStores = this._resolveStores(config.stores, toolConfig.stores) + this._searchToolConfig = toolConfig + } + + if (config.storeToolConfig === undefined || config.storeToolConfig === false) { + this._storeToolConfig = false + this._storeStores = [] + } else { + const toolConfig = typeof config.storeToolConfig === 'object' ? config.storeToolConfig : {} + const resolved = this._resolveStores(config.stores, toolConfig.stores).filter((s) => s.add) + + if (resolved.length === 0) { + throw new Error('MemoryManager: storeToolConfig targets no writable stores') + } + + if (config.storeToolConfig === true && resolved.length > 1 && !toolConfig.stores) { + throw new Error( + 'MemoryManager: storeToolConfig must specify `stores` when multiple writable stores are configured' + ) + } + + this._storeStores = resolved + this._storeToolConfig = toolConfig + } } /** @@ -65,23 +98,19 @@ export class MemoryManager implements Plugin { initAgent(_agent: LocalAgent): void {} /** - * Returns tools registered by this plugin (controlled by `includeTools` config). + * Returns tools registered by this plugin. * * @returns Array of tools to register with the agent */ getTools(): Tool[] { - if (this._toolsConfig === false) return [] - const tools: Tool[] = [] - const searchConfig = this._toolsConfig.search - if (searchConfig !== false) { - tools.push(this._createSearchTool(typeof searchConfig === 'object' ? searchConfig : undefined)) + if (this._searchToolConfig !== false) { + tools.push(this._createSearchTool(this._searchToolConfig)) } - const storeConfig = this._toolsConfig.store - if (storeConfig !== false && this._hasWritableStore()) { - tools.push(this._createStoreTool(typeof storeConfig === 'object' ? storeConfig : undefined)) + if (this._storeToolConfig !== false) { + tools.push(this._createStoreTool(this._storeToolConfig)) } return tools @@ -95,13 +124,13 @@ export class MemoryManager implements Plugin { * * @param query - The search query string * @param options - Optional limit per-store and store name filter - * @returns Array of knowledge entries from matching stores + * @returns Array of memory entries from matching stores */ - async search(query: string, options?: MemorySearchOptions): Promise { + async search(query: string, options?: MemorySearchOptions): Promise { logger.debug(`query=<${query}>, limit=<${options?.limit}>, stores=<${options?.stores}> | searching stores`) const targetStores = options?.stores?.length - ? this._config.stores.filter((s) => s.name && options.stores!.includes(s.name)) + ? this._config.stores.filter((s) => options.stores!.includes(s.name)) : this._config.stores if (options?.stores?.length && targetStores.length === 0) { @@ -110,14 +139,14 @@ export class MemoryManager implements Plugin { const limit = options?.limit const settled = await Promise.allSettled( - targetStores.map((config) => config.store.search(query, { limit: limit ?? config.limit ?? 10 })) + targetStores.map((store) => store.search(query, { limit: limit ?? store.limit ?? DEFAULT_RESULTS_PER_STORE })) ) - const results: KnowledgeEntry[] = [] + const results: MemoryEntry[] = [] for (let i = 0; i < settled.length; i++) { const r = settled[i]! if (r.status === 'rejected') { - logger.warn(`store=<${targetStores[i]!.name ?? i}>, reason=<${r.reason}> | store search failed`) + logger.warn(`store=<${targetStores[i]!.name}>, reason=<${r.reason}> | store search failed`) continue } for (const entry of r.value) { @@ -138,23 +167,23 @@ export class MemoryManager implements Plugin { * @param options - Optional metadata and store name filter */ async store(content: string, options?: MemoryStoreOptions): Promise { - let writableStores = this._config.stores.filter((s) => s.store.add) + let writableStores = this._config.stores.filter((s) => s.add) if (options?.stores?.length) { - writableStores = writableStores.filter((s) => s.name && options.stores!.includes(s.name)) + writableStores = writableStores.filter((s) => options.stores!.includes(s.name)) } if (writableStores.length === 0) { - throw new Error('MemoryManager: no writable store configured') + throw new Error('MemoryManager: no writable store matched') } - const settled = await Promise.allSettled(writableStores.map((s) => s.store.add!(content, options?.metadata))) + 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 ?? String(i) + const storeName = writableStores[i]!.name logger.warn(`store=<${storeName}>, reason=<${r.reason}> | store write failed`) failures.push({ store: storeName, reason: r.reason }) } @@ -167,15 +196,26 @@ export class MemoryManager implements Plugin { } } - private _hasWritableStore(): boolean { - return this._config.stores.some((s) => s.store.add) + private _resolveStores(allStores: MemoryStore[], scoped?: (string | MemoryStore)[]): MemoryStore[] { + if (!scoped || scoped.length === 0) return allStores + + return scoped.map((ref) => { + if (typeof ref === 'string') { + const found = allStores.find((s) => s.name === ref) + if (!found) { + throw new Error(`MemoryManager: store '${ref}' not found`) + } + return found + } + return ref + }) } - private _createSearchTool(config?: { name?: string; description?: string }): Tool { - let description = config?.description ?? SEARCH_TOOL_DESCRIPTION - const storeDescriptions = this._config.stores - .filter((s) => s.name || s.description) - .map((s) => `- ${s.name ?? 'unnamed'}${s.description ? `: ${s.description}` : ''}`) + 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 ? `: ${s.description}` : ''}`) if (storeDescriptions.length > 0) { description += `\n\nAvailable memory stores:\n${storeDescriptions.join('\n')}` description += @@ -189,7 +229,7 @@ export class MemoryManager implements Plugin { }) return tool({ - name: config?.name ?? 'search_memory', + name: config.name ?? 'search_memory', description, inputSchema, callback: async (input) => { @@ -198,7 +238,6 @@ export class MemoryManager implements Plugin { ...(input.stores != null && { stores: input.stores }), }) return results.map((entry) => ({ - id: entry.id, content: entry.content, ...(entry.metadata && { metadata: entry.metadata }), })) as JSONValue @@ -206,13 +245,11 @@ export class MemoryManager implements Plugin { }) } - private _createStoreTool(config?: { name?: string; description?: string }): Tool { - const writableStores = this._config.stores.filter((s) => s.store.add) - - let description = config?.description ?? STORE_TOOL_DESCRIPTION - const storeDescriptions = writableStores - .filter((s) => s.name || s.description) - .map((s) => `- ${s.name ?? 'unnamed'}${s.description ? `: ${s.description}` : ''}`) + private _createStoreTool(config: MemoryToolConfig): Tool { + let description = config.description ?? STORE_TOOL_DESCRIPTION + const storeDescriptions = this._storeStores + .filter((s) => s.description) + .map((s) => `- ${s.name}${s.description ? `: ${s.description}` : ''}`) if (storeDescriptions.length > 0) { description += `\n\nAvailable writable stores:\n${storeDescriptions.join('\n')}` description += @@ -225,7 +262,7 @@ export class MemoryManager implements Plugin { }) return tool({ - name: config?.name ?? 'store_memory', + name: config.name ?? 'store_memory', description, inputSchema, callback: async (input) => { diff --git a/strands-ts/src/memory/types.ts b/strands-ts/src/memory/types.ts index dda178a93..40a146b9a 100644 --- a/strands-ts/src/memory/types.ts +++ b/strands-ts/src/memory/types.ts @@ -1,17 +1,17 @@ +import type { JSONValue } from '../types/json.js' + /** - * A single entry retrieved from or stored to a knowledge store. + * A single entry retrieved from or stored to a memory store. */ -export interface KnowledgeEntry { - /** Unique identifier for this entry, assigned by the store. */ - id: string - /** The textual content of this knowledge entry. */ +export interface MemoryEntry { + /** The textual content of this memory entry. */ content: string - /** Optional metadata (e.g., score, source, timestamp). */ - metadata?: Record + /** Optional metadata (e.g., score, source, id, timestamp). */ + metadata?: Record } /** - * Options passed to {@link KnowledgeStore.search}. + * Options passed to {@link MemoryStore.search}. * Store implementations may extend this with additional fields in their own signatures. */ export interface SearchOptions { @@ -20,24 +20,21 @@ export interface SearchOptions { } /** - * Interface for a knowledge store backend. + * Interface for a memory store backend. * * Only `search` is required. Stores that support mutation may additionally implement `add`. - * - * @example - * ```typescript - * const store: KnowledgeStore = { - * async search(query, options) { - * return myVectorDb.query(query, { topK: options?.limit ?? 10 }) - * } - * } - * ``` */ -export interface KnowledgeStore { +export interface MemoryStore { + /** Identifier for this store, used to target specific stores in search/store tools. */ + readonly name: string + /** Human-readable description of what this store contains. Included in tool descriptions. */ + readonly description?: string + /** Default max results per query for this store. Defaults to 3. */ + readonly limit?: number /** Search the store for entries matching the query, ordered by relevance. */ - search(query: string, options?: SearchOptions): Promise + search(query: string, options?: SearchOptions): Promise /** Add content to the store. Optional — only present on mutable stores. */ - add?(content: string, metadata?: Record): Promise + add?(content: string, metadata?: Record): Promise } /** @@ -55,60 +52,31 @@ export interface MemorySearchOptions { */ export interface MemoryStoreOptions { /** Metadata to associate with the stored entry. */ - metadata?: Record + metadata?: Record /** Filter to specific writable stores by name. Omit to write to all. */ stores?: string[] } /** - * Configuration for a single knowledge store within the memory manager. - */ -export interface StoreConfig { - /** The knowledge store instance. */ - store: KnowledgeStore - /** Identifier for this store, used to target specific stores in search. */ - name?: string - /** Human-readable description of what this store contains. */ - description?: string - /** Default max results per query for this store. Defaults to 10. */ - limit?: number -} - -/** - * Configuration for customizing a memory tool's name or description. + * Configuration for customizing a memory tool's name, description, or store scoping. */ export interface MemoryToolConfig { /** Custom tool name. */ name?: string /** Custom tool description. */ description?: string -} - -/** - * Configuration for which tools the memory manager exposes to the agent. - */ -export interface MemoryToolsConfig { - /** Configuration for the search tool. `false` disables it. */ - search?: boolean | MemoryToolConfig - /** Configuration for the store tool. `false` disables it. */ - store?: boolean | MemoryToolConfig + /** Scopes which stores this tool targets. Defaults to all applicable stores. */ + stores?: (string | MemoryStore)[] } /** * Configuration for the {@link MemoryManager}. - * - * @example - * ```typescript - * // Config shorthand (auto-wrapped into MemoryManager) - * const agent = new Agent({ - * model, - * memoryManager: { stores: [{ store: myKnowledgeBase }] }, - * }) - * ``` */ export interface MemoryManagerConfig { - /** One or more knowledge stores to query. */ - stores: StoreConfig[] - /** Whether to register tools for agent-driven search and storage. Defaults to `true`. */ - includeTools?: boolean | MemoryToolsConfig + /** One or more memory stores to manage. */ + stores: MemoryStore[] + /** Search tool configuration. Defaults to `true` (auto-created targeting all stores). */ + searchToolConfig?: MemoryToolConfig | boolean + /** Store tool configuration. Defaults to `false` (opt-in). */ + storeToolConfig?: MemoryToolConfig | boolean }