feat: add extraction defaults and support for bedrock kbs (#2719)

This commit is contained in:
opieter-aws
2026-06-11 12:40:44 -04:00
committed by GitHub
parent 3a3fe7bc23
commit 13839973d3
9 changed files with 384 additions and 44 deletions
@@ -1,7 +1,13 @@
import { describe, it, expect, vi } from 'vitest'
import { MemoryManager } from '../../memory-manager.js'
import { InvocationTrigger, IntervalTrigger } from '../triggers.js'
import { ExtractionCoordinator, SAVE_FAILURES_BEFORE_BACKOFF, BACKOFF_PROBE_INTERVAL } from '../coordinator.js'
import {
ExtractionCoordinator,
SAVE_FAILURES_BEFORE_BACKOFF,
BACKOFF_PROBE_INTERVAL,
type ExtractionBinding,
} from '../coordinator.js'
import { resolveExtractionConfig } from '../resolve-extraction-config.js'
import type { Model } from '../../../models/model.js'
import type { ExtractionConfig, Extractor } from '../types.js'
import type { MemoryStore, MemoryEntry, AddMessagesContext } from '../../types.js'
@@ -9,6 +15,7 @@ import type { MessageData } from '../../../types/messages.js'
import { Message, TextBlock, ToolUseBlock } from '../../../types/messages.js'
import { AfterInvocationEvent, MessageAddedEvent } from '../../../hooks/events.js'
import { createMockAgent, type MockAgent } from '../../../__fixtures__/agent-helpers.js'
import { MockMessageModel } from '../../../__fixtures__/mock-message-model.js'
/**
* Builds a writable store with an extraction config. `sink` chooses which write method(s) it has,
@@ -17,7 +24,7 @@ import { createMockAgent, type MockAgent } from '../../../__fixtures__/agent-hel
*/
function createExtractionStore(
name: string,
extraction: ExtractionConfig,
extraction: boolean | ExtractionConfig | undefined,
sink: 'add' | 'addMessages' | 'both' = 'both',
options?: { entries?: MemoryEntry[] }
): MemoryStore & {
@@ -38,6 +45,11 @@ function createExtractionStore(
return store
}
/** Wraps a store into the {@link ExtractionBinding} pair the coordinator takes, resolving its config. */
function asExtractionStore(store: MemoryStore): ExtractionBinding {
return { store, config: resolveExtractionConfig(store.extraction, store)! }
}
function userMsg(text: string): Message {
return new Message({ role: 'user', content: [new TextBlock(text)] })
}
@@ -104,10 +116,60 @@ describe('MemoryManager extraction', () => {
const store = createExtractionStore('s', { trigger: [new InvocationTrigger()], extractor }, 'addMessages')
expect(() => new MemoryManager({ stores: [store] })).toThrow('has an extractor but no add method')
})
})
it('throws when extraction has no extractor but the store has no addMessages', () => {
describe('config resolution', () => {
it('enables extraction with defaults via the boolean shorthand', async () => {
// `extraction: true` on an addMessages-capable store -> passthrough (no implicit model call).
const store = createExtractionStore('s', true, 'addMessages')
const mm = new MemoryManager({ stores: [store] })
const agent = createMockAgent()
mm.initAgent(agent)
await addMessages(agent, userMsg('remember this'))
// The default trigger fires every DEFAULT_EXTRACTION_TRIGGER_TURNS turns; flush forces the write.
await mm.flush()
expect(store.addMessages).toHaveBeenCalledTimes(1)
})
it('treats extraction: false as disabled (no hooks, no writes)', async () => {
const store = createExtractionStore('s', false, 'addMessages')
const mm = new MemoryManager({ stores: [store] })
const agent = createMockAgent()
mm.initAgent(agent)
await addMessages(agent, userMsg('ignored'))
await mm.flush()
expect(store.addMessages).not.toHaveBeenCalled()
expect(agent.trackedHooks.filter((h) => h.eventType === AfterInvocationEvent)).toHaveLength(0)
})
it('defaults an add-only store to a ModelExtractor (extractor route)', async () => {
const store = createExtractionStore('s', { trigger: [new InvocationTrigger()] }, 'add')
expect(() => new MemoryManager({ stores: [store] })).toThrow('without an extractor but no addMessages method')
// The default ModelExtractor calls the agent's model; the mock returns a JSON array of facts.
const model = new MockMessageModel()
model.addTurn({ type: 'textBlock', text: '[{"content":"a durable fact"}]' })
const mm = new MemoryManager({ stores: [store] })
const agent = createMockAgent({ extra: { model: model as unknown as Model } })
mm.initAgent(agent)
await addMessages(agent, userMsg('I like dark mode'))
await fireInvocation(agent, mm)
expect(store.add).toHaveBeenCalledWith('a durable fact', undefined)
})
it('defaults a both-sinks store to the passthrough (no implicit model call)', async () => {
const store = createExtractionStore('s', { trigger: [new InvocationTrigger()] }, 'both')
const mm = new MemoryManager({ stores: [store] })
const agent = createMockAgent()
mm.initAgent(agent)
await addMessages(agent, userMsg('hi'))
await fireInvocation(agent, mm)
expect(store.addMessages).toHaveBeenCalledTimes(1)
expect(store.add).not.toHaveBeenCalled()
})
})
@@ -439,7 +501,7 @@ describe('MemoryManager extraction', () => {
} {
const store = createExtractionStore('s', { trigger: [new InvocationTrigger()] }, 'addMessages')
store.addMessages.mockRejectedValue(new Error('backend down'))
const coordinator = new ExtractionCoordinator([store], {} as Model)
const coordinator = new ExtractionCoordinator([asExtractionStore(store)], {} as Model)
return { coordinator, store }
}
@@ -488,7 +550,7 @@ describe('MemoryManager extraction', () => {
const bad = createExtractionStore('bad', { trigger: [new InvocationTrigger()] }, 'addMessages')
bad.addMessages.mockRejectedValue(new Error('down'))
const good = createExtractionStore('good', { trigger: [new InvocationTrigger()] }, 'addMessages')
const coordinator = new ExtractionCoordinator([bad, good], {} as Model)
const coordinator = new ExtractionCoordinator([asExtractionStore(bad), asExtractionStore(good)], {} as Model)
const PROBES = 2
const requests = SAVE_FAILURES_BEFORE_BACKOFF + BACKOFF_PROBE_INTERVAL * PROBES
@@ -518,7 +580,7 @@ describe('MemoryManager extraction', () => {
it('flush bypasses backoff and writes the backlog of a recovered store', async () => {
const store = createExtractionStore('s', { trigger: [new InvocationTrigger()] }, 'addMessages')
store.addMessages.mockRejectedValue(new Error('down'))
const coordinator = new ExtractionCoordinator([store], {} as Model)
const coordinator = new ExtractionCoordinator([asExtractionStore(store)], {} as Model)
// Drive the store into backoff.
for (let i = 0; i < SAVE_FAILURES_BEFORE_BACKOFF; i++) {
@@ -547,7 +609,7 @@ describe('MemoryManager extraction', () => {
// clear the prior failures. We prove that by showing backoff still engages at the threshold.
const store = createExtractionStore('s', { trigger: [new InvocationTrigger()] }, 'addMessages')
store.addMessages.mockRejectedValue(new Error('down'))
const coordinator = new ExtractionCoordinator([store], {} as Model)
const coordinator = new ExtractionCoordinator([asExtractionStore(store)], {} as Model)
// One short of backoff.
for (let i = 0; i < SAVE_FAILURES_BEFORE_BACKOFF - 1; i++) {
@@ -0,0 +1,95 @@
import { describe, it, expect, vi } from 'vitest'
import { resolveExtractionConfig, DEFAULT_EXTRACTION_TRIGGER_TURNS } from '../resolve-extraction-config.js'
import { IntervalTrigger, InvocationTrigger } from '../triggers.js'
import { ModelExtractor } from '../model-extractor.js'
import { DEFAULT_MEMORY_MESSAGE_FILTER, type Extractor } from '../types.js'
import type { MemoryStore } from '../../types.js'
/** A minimal store stub exposing only the write sinks the resolver inspects. */
function sinks(have: 'add' | 'addMessages' | 'both'): Pick<MemoryStore, 'add' | 'addMessages'> {
return {
...((have === 'add' || have === 'both') && { add: vi.fn() }),
...((have === 'addMessages' || have === 'both') && { addMessages: vi.fn() }),
}
}
describe('resolveExtractionConfig', () => {
describe('enablement shorthand', () => {
it('returns undefined when extraction is false', () => {
expect(resolveExtractionConfig(false, sinks('add'))).toBeUndefined()
})
it('returns undefined when extraction is undefined', () => {
expect(resolveExtractionConfig(undefined, sinks('add'))).toBeUndefined()
})
it('resolves true to a fully-defaulted config', () => {
const resolved = resolveExtractionConfig(true, sinks('add'))
expect(resolved).toBeDefined()
expect(resolved!.triggers).toHaveLength(1)
expect(resolved!.triggers[0]).toBeInstanceOf(IntervalTrigger)
expect(resolved!.filter).toBe(DEFAULT_MEMORY_MESSAGE_FILTER)
})
})
describe('trigger defaulting and normalization', () => {
it('defaults an omitted trigger to an IntervalTrigger of DEFAULT_EXTRACTION_TRIGGER_TURNS', () => {
const resolved = resolveExtractionConfig({}, sinks('addMessages'))!
// Structural equality compares the constructed IntervalTrigger (including its turns) without
// reaching into private fields, so it stays valid if IntervalTrigger's internals change.
expect(resolved.triggers).toEqual([new IntervalTrigger({ turns: DEFAULT_EXTRACTION_TRIGGER_TURNS })])
expect(DEFAULT_EXTRACTION_TRIGGER_TURNS).toBe(5)
})
it('wraps a single trigger into an array', () => {
const trigger = new InvocationTrigger()
const resolved = resolveExtractionConfig({ trigger }, sinks('addMessages'))!
expect(resolved.triggers).toEqual([trigger])
})
it('passes an explicit trigger array through unchanged', () => {
const triggers = [new InvocationTrigger(), new IntervalTrigger({ turns: 2 })]
const resolved = resolveExtractionConfig({ trigger: triggers }, sinks('addMessages'))!
expect(resolved.triggers).toEqual(triggers)
})
it('leaves an explicit empty trigger array empty (so the manager can reject it)', () => {
const resolved = resolveExtractionConfig({ trigger: [] }, sinks('addMessages'))!
expect(resolved.triggers).toEqual([])
})
})
describe('capability-based extractor default', () => {
it('defaults an add-only store to a ModelExtractor', () => {
const resolved = resolveExtractionConfig(true, sinks('add'))!
expect(resolved.extractor).toBeInstanceOf(ModelExtractor)
})
it('defaults an addMessages-only store to the passthrough (no extractor)', () => {
const resolved = resolveExtractionConfig(true, sinks('addMessages'))!
expect(resolved.extractor).toBeUndefined()
})
it('defaults a both-sinks store to the passthrough (no extractor)', () => {
const resolved = resolveExtractionConfig(true, sinks('both'))!
expect(resolved.extractor).toBeUndefined()
})
it('keeps an explicit extractor even on an addMessages store', () => {
const extractor: Extractor = { extract: vi.fn() }
const resolved = resolveExtractionConfig({ extractor }, sinks('both'))!
expect(resolved.extractor).toBe(extractor)
})
})
describe('filter defaulting', () => {
it('defaults to DEFAULT_MEMORY_MESSAGE_FILTER', () => {
expect(resolveExtractionConfig(true, sinks('add'))!.filter).toBe(DEFAULT_MEMORY_MESSAGE_FILTER)
})
it('passes an explicit filter through', () => {
const filter = { exclude: [] as never[] }
expect(resolveExtractionConfig({ filter }, sinks('add'))!.filter).toBe(filter)
})
})
})
+31 -10
View File
@@ -3,12 +3,30 @@ import type { MessageData, ContentBlockData } from '../../types/messages.js'
import type { Model } from '../../models/model.js'
import { logger } from '../../logging/logger.js'
import { normalizeError } from '../../errors.js'
import { DEFAULT_MEMORY_MESSAGE_FILTER, type MemoryMessageFilter } from './types.js'
import type { MemoryMessageFilter } from './types.js'
import type { ResolvedExtractionConfig } from './resolve-extraction-config.js'
/** Number of consecutive save failures after which a store backs off (stops trying every turn). */
/**
* A store paired with its fully-resolved extraction config.
* @internal
*/
export interface ExtractionBinding {
/** The memory store to extract into. */
store: MemoryStore
/** The store's fully-resolved extraction config (triggers, extractor, filter). */
config: ResolvedExtractionConfig
}
/**
* Number of consecutive save failures after which a store backs off (stops trying every turn).
* @internal
*/
export const SAVE_FAILURES_BEFORE_BACKOFF = 10
/** While backed off, a store retries only once every this many save attempts (a probe). */
/**
* While backed off, a store retries only once every this many save attempts (a probe).
* @internal
*/
export const BACKOFF_PROBE_INTERVAL = 3
/** A buffered message and its sequence number. */
@@ -74,9 +92,12 @@ function _filterMessages(buffered: BufferedMessage[], filter: MemoryMessageFilte
*
* Saving itself either runs the store's extractor to pull out facts, or hands the raw messages to the
* store - see {@link _write}.
* @internal
*/
export class ExtractionCoordinator {
private readonly _stores: MemoryStore[]
/** Per store: its resolved extraction config (triggers, extractor, filter). */
private readonly _storeToExtractionConfig = new Map<MemoryStore, ResolvedExtractionConfig>()
private readonly _defaultModel: Model
/** The shared list of messages waiting to be saved, oldest first. Each is tagged with its `seq`. */
private _pending: BufferedMessage[] = []
@@ -92,13 +113,14 @@ export class ExtractionCoordinator {
private readonly _backoffCounters = new Map<MemoryStore, number>()
/**
* @param stores - The extraction-configured stores this coordinator manages
* @param stores - The extraction-configured stores this coordinator manages, each with its resolved config
* @param defaultModel - The agent's model, passed to extractors that don't configure their own
*/
constructor(stores: MemoryStore[], defaultModel: Model) {
this._stores = stores
constructor(stores: ExtractionBinding[], defaultModel: Model) {
this._stores = stores.map((s) => s.store)
this._defaultModel = defaultModel
for (const store of stores) {
for (const { store, config } of stores) {
this._storeToExtractionConfig.set(store, config)
this._marks.set(store, -1)
}
}
@@ -177,8 +199,7 @@ export class ExtractionCoordinator {
const highestSeq = fresh[fresh.length - 1]!.seq
this._marks.set(store, highestSeq)
const extraction = store.extraction!
const filter = extraction.filter ?? DEFAULT_MEMORY_MESSAGE_FILTER
const filter = this._storeToExtractionConfig.get(store)!.filter
const filtered = _filterMessages(fresh, filter)
try {
@@ -227,7 +248,7 @@ export class ExtractionCoordinator {
* next time - so a fact that already saved may be written again (stores should expect duplicates).
*/
private async _write(store: MemoryStore, buffered: BufferedMessage[]): Promise<void> {
const extractor = store.extraction!.extractor
const extractor = this._storeToExtractionConfig.get(store)!.extractor
const messages = buffered.map((buffer) => buffer.message)
if (extractor) {
@@ -0,0 +1,93 @@
import type { MemoryStore } from '../types.js'
import { IntervalTrigger } from './triggers.js'
import { ModelExtractor } from './model-extractor.js'
import {
DEFAULT_MEMORY_MESSAGE_FILTER,
type ExtractionConfig,
type ExtractionTrigger,
type Extractor,
type MemoryMessageFilter,
} from './types.js'
/**
* Default cadence when an {@link ExtractionConfig} omits its `trigger`: extract every N turns.
* @internal
*/
export const DEFAULT_EXTRACTION_TRIGGER_TURNS = 5
/**
* An {@link ExtractionConfig} with every field resolved to a concrete value, ready to drive
* extraction. Produced by {@link resolveExtractionConfig} so the {@link MemoryManager} and
* {@link ExtractionCoordinator} never have to re-apply defaults or normalize shapes.
* @internal
*/
export interface ResolvedExtractionConfig {
/** Normalized to an array (a single trigger is wrapped). Never empty for a resolved config. */
triggers: ExtractionTrigger[]
/**
* The extractor that distills facts client-side and stores them via the store's `add` method, or
* `undefined` to use the store's `addMessages` method (server-side extraction).
*/
extractor?: Extractor
/** The content-block filter applied before extraction. */
filter: MemoryMessageFilter
}
/**
* Resolves a store's `extraction` setting into a {@link ResolvedExtractionConfig}, applying defaults.
*
* The single place the `boolean | ExtractionConfig` shorthand is interpreted: `false`/omitted is off
* (returns `undefined`), `true` enables all defaults, an {@link ExtractionConfig} defaults its unset
* fields. The defaults are:
* - **trigger**: every {@link DEFAULT_EXTRACTION_TRIGGER_TURNS} turns. An explicit empty array is left
* empty for the {@link MemoryManager} to reject.
* - **extractor**: chosen from the methods the store implements. A store that implements `addMessages`
* supports server-side extraction, so it defaults to no extractor: the manager hands raw messages to
* `addMessages` and the backend extracts them itself, with no model call. A store that implements only
* `add` cannot extract server-side, so it defaults to a {@link ModelExtractor} that distills facts
* client-side (via model calls) and stores each one through `add`.
* - **filter**: {@link DEFAULT_MEMORY_MESSAGE_FILTER}.
*
* @param extraction - The store's `extraction` setting
* @param store - The store, inspected for the write methods it implements to pick the default extractor
* @returns The resolved config, or `undefined` when extraction is disabled
* @internal
*/
export function resolveExtractionConfig(
extraction: boolean | ExtractionConfig | undefined,
store: Pick<MemoryStore, 'add' | 'addMessages'>
): ResolvedExtractionConfig | undefined {
if (!extraction) {
return undefined
}
const config: ExtractionConfig = extraction === true ? {} : extraction
const triggers =
config.trigger === undefined
? [new IntervalTrigger({ turns: DEFAULT_EXTRACTION_TRIGGER_TURNS })]
: Array.isArray(config.trigger)
? config.trigger
: [config.trigger]
let extractor = config.extractor
if (extractor === undefined) {
// Pick the default extractor from the store's write methods:
// - implements `addMessages` (whether or not it also implements `add`): extract server-side. Leave
// the extractor undefined so raw messages go straight to `addMessages` with no model call.
// - implements only `add`: it cannot extract server-side, so default to a ModelExtractor that
// distills facts client-side and stores each via `add`.
const implementsAdd = typeof store.add === 'function'
const implementsAddMessages = typeof store.addMessages === 'function'
if (implementsAdd && !implementsAddMessages) {
extractor = new ModelExtractor()
}
}
const filter = config.filter ?? DEFAULT_MEMORY_MESSAGE_FILTER
return {
triggers,
...(extractor !== undefined && { extractor }),
filter,
}
}
+9 -7
View File
@@ -122,15 +122,17 @@ export abstract class ExtractionTrigger {
*/
export interface ExtractionConfig {
/**
* When to run extraction. A single trigger or an array; an empty array is rejected at
* construction. Multiple triggers compose (extraction runs whenever any of them fires).
* When to run extraction. A single trigger or an array; multiple triggers compose (extraction runs
* whenever any of them fires). Omit to default to every 5 turns; an explicit empty array is
* rejected at construction.
*/
trigger: ExtractionTrigger | ExtractionTrigger[]
trigger?: ExtractionTrigger | ExtractionTrigger[]
/**
* How to turn messages into entries. When set, the store must implement `add` (entries are written
* to it). When omitted, the manager hands the filtered messages straight to the store's
* `addMessages` (which the store must then implement) — so backends that extract server-side need
* no client-side extractor.
* How to turn messages into entries (client-side extraction). When set, the store must implement
* `add` and each produced entry is stored through it. When omitted, the default depends on the
* store's write methods: a store implementing `addMessages` uses server-side extraction (the manager
* hands it the raw messages, no model call), while a store implementing only `add` defaults to a
* {@link ModelExtractor} that distills facts client-side.
*/
extractor?: Extractor
/**
+12 -14
View File
@@ -12,8 +12,8 @@ import type {
} from './types.js'
import type { JSONValue } from '../types/json.js'
import { MessageAddedEvent } from '../hooks/events.js'
import { ExtractionCoordinator } from './extraction/coordinator.js'
import type { ExtractionTrigger } from './extraction/types.js'
import { ExtractionCoordinator, type ExtractionBinding } from './extraction/coordinator.js'
import { resolveExtractionConfig } from './extraction/resolve-extraction-config.js'
import { tool } from '../tools/tool-factory.js'
import { z } from 'zod'
import { logger } from '../logging/logger.js'
@@ -44,11 +44,6 @@ function _hasWriteSink(store: MemoryStore): boolean {
return typeof store.add === 'function' || typeof store.addMessages === 'function'
}
/** Normalizes a store's `trigger` field (a single trigger or an array) to an array. */
function _normalizeTriggers(trigger: ExtractionTrigger | ExtractionTrigger[]): ExtractionTrigger[] {
return Array.isArray(trigger) ? trigger : [trigger]
}
/**
* Provides cross-session memory retrieval and storage for agents.
*
@@ -81,8 +76,8 @@ export class MemoryManager implements Plugin {
private readonly _searchToolConfig: MemoryToolConfig | false
private readonly _addToolConfig: MemoryAddToolConfig | false
private readonly _addToolStores: MemoryStore[]
/** Stores with an extraction config and at least one trigger; wired up in {@link initAgent}. */
private readonly _extractionStores: MemoryStore[]
/** Stores with extraction enabled, each paired with its resolved config; wired up in {@link initAgent}. */
private readonly _extractionStores: ExtractionBinding[]
/** Background extraction coordinator, created in {@link initAgent} when extraction is configured. */
private _coordinator: ExtractionCoordinator | undefined
@@ -92,6 +87,7 @@ export class MemoryManager implements Plugin {
}
const seenNames = new Set<string>()
const extractionStores: ExtractionBinding[] = []
for (const store of config.stores) {
if (seenNames.has(store.name)) {
throw new Error(`MemoryManager: duplicate store name '${store.name}'`)
@@ -106,12 +102,13 @@ export class MemoryManager implements Plugin {
if (!store.writable) {
throw new Error(`MemoryManager: store '${store.name}' has extraction config but is not writable`)
}
if (_normalizeTriggers(store.extraction.trigger).length === 0) {
const resolved = resolveExtractionConfig(store.extraction, store)!
if (resolved.triggers.length === 0) {
throw new Error(`MemoryManager: store '${store.name}' has extraction config but no triggers`)
}
// Each extraction shape needs its matching write sink. An extractor produces discrete entries
// written via `add`; without an extractor the raw message batch goes to `addMessages`.
if (store.extraction.extractor) {
if (resolved.extractor) {
if (typeof store.add !== 'function') {
throw new Error(
`MemoryManager: store '${store.name}' has an extractor but no add method (extracted entries are written via add)`
@@ -122,6 +119,7 @@ export class MemoryManager implements Plugin {
`MemoryManager: store '${store.name}' has extraction config without an extractor but no addMessages method`
)
}
extractionStores.push({ store, config: resolved })
}
}
@@ -129,7 +127,7 @@ export class MemoryManager implements Plugin {
this._searchStores = config.stores
// `add`-targeting paths (tool / programmatic) need an `add` method specifically.
this._addStores = config.stores.filter((s) => s.writable && typeof s.add === 'function')
this._extractionStores = config.stores.filter((s) => s.writable && s.extraction)
this._extractionStores = extractionStores
this._searchToolConfig =
config.searchToolConfig === false
@@ -199,8 +197,8 @@ export class MemoryManager implements Plugin {
coordinator.record(event.message.toJSON())
})
for (const store of this._extractionStores) {
for (const trigger of _normalizeTriggers(store.extraction!.trigger)) {
for (const { store, config } of this._extractionStores) {
for (const trigger of config.triggers) {
trigger.attach({ agent, fire: () => void coordinator.process(store) })
}
}
+11 -4
View File
@@ -75,11 +75,18 @@ export interface MemoryStoreConfig {
*/
readonly writable?: boolean
/**
* Automatic-extraction configuration for this store. When set, the {@link MemoryManager} runs the
* configured triggers and writes extracted (or, with no extractor, raw) messages to this store.
* Requires the store to be writable. Omit for a purely tool-driven store.
* Automatic-extraction config for this writable store, as a `boolean | config` shorthand. `true`
* enables it with defaults; an {@link ExtractionConfig} defaults any unset field; `false`/omitted is off.
*
* The defaults run every 5 turns, and the extraction method depends on the store's write methods. A
* store implementing `addMessages` uses server-side extraction: the manager hands it the raw messages
* and the backend extracts them, with no model call. A store implementing only `add` uses a
* {@link ModelExtractor} for client-side extraction: it calls the agent's model to distill facts and
* stores each one via `add`.
*
* @defaultValue false
*/
readonly extraction?: ExtractionConfig
readonly extraction?: boolean | ExtractionConfig
}
/**
@@ -3,6 +3,11 @@ import { BedrockAgentRuntimeClient } from '@aws-sdk/client-bedrock-agent-runtime
import { BedrockAgentClient } from '@aws-sdk/client-bedrock-agent'
import { BedrockKnowledgeBaseStore } from '../store.js'
import { MemoryManager } from '../../../memory/index.js'
import { InvocationTrigger } from '../../../memory/extraction/triggers.js'
import type { Extractor } from '../../../memory/extraction/types.js'
import { Message, TextBlock } from '../../../types/messages.js'
import { AfterInvocationEvent, MessageAddedEvent } from '../../../hooks/events.js'
import { createMockAgent } from '../../../__fixtures__/agent-helpers.js'
import { logger } from '../../../logging/logger.js'
// Mock the AWS SDK clients. Command classes are stubbed to echo their input as `{ input }`, so a
@@ -182,6 +187,28 @@ describe('BedrockKnowledgeBaseStore', () => {
})
})
describe('extraction config', () => {
const extractor = { extract: vi.fn() }
it('defaults extraction to undefined', () => {
const { store } = makeStore()
expect(store.extraction).toBeUndefined()
})
it('exposes a configured extraction config verbatim', () => {
const extraction = { trigger: new InvocationTrigger(), extractor }
const { store } = makeCustomStore({ extraction })
expect(store.extraction).toBe(extraction)
})
it('exposes the boolean shorthand verbatim', () => {
// The store passes `extraction` straight through; the MemoryManager resolves the shorthand and
// (since a KB is add-only, with no addMessages) defaults it to a ModelExtractor.
const { store } = makeCustomStore({ extraction: true })
expect(store.extraction).toBe(true)
})
})
describe('search', () => {
it('issues a RetrieveCommand with the query and a default result limit of 10', async () => {
const { store, runtime } = makeStore()
@@ -682,4 +709,36 @@ describe('BedrockKnowledgeBaseStore', () => {
debugSpy.mockRestore()
})
})
// End-to-end: a writable CUSTOM store carrying an extraction config is picked up by MemoryManager,
// and on a trigger fire the extracted facts are ingested through the store's own `add` (i.e. via
// IngestKnowledgeBaseDocuments on the injected agent client). No AWS calls; clients are mocked.
describe('extraction via MemoryManager', () => {
it('ingests extracted facts through add when the trigger fires', async () => {
const extractor: Extractor = {
extract: vi.fn().mockResolvedValue([{ content: 'user prefers dark mode' }]),
}
const { store, agent: agentClient } = makeCustomStore({
extraction: { trigger: new InvocationTrigger(), extractor },
})
const mm = new MemoryManager({ stores: [store] })
const agent = createMockAgent()
mm.initAgent(agent)
// Buffer a turn, then fire the after-invocation hook and flush the background save.
const message = new Message({ role: 'user', content: [new TextBlock('I like dark mode')] })
const added = agent.trackedHooks.filter((h) => h.eventType === MessageAddedEvent)
for (const hook of added) await hook.callback(new MessageAddedEvent({ agent, message, invocationState: {} }))
const after = agent.trackedHooks.filter((h) => h.eventType === AfterInvocationEvent)
for (const hook of after) await hook.callback(new AfterInvocationEvent({ agent, invocationState: {} }))
await mm.flush()
expect(extractor.extract).toHaveBeenCalledTimes(1)
// The fact was ingested via IngestKnowledgeBaseDocuments with the extracted content.
expect(agentClient.send).toHaveBeenCalledTimes(1)
const document = agentClient.send.mock.calls[0]?.[0].input.documents[0]
expect(document.content.custom.inlineContent.textContent.data).toBe('user prefers dark mode')
})
})
})
@@ -9,6 +9,7 @@ import type { S3Client } from '@aws-sdk/client-s3'
import { v7 as uuidv7 } from 'uuid'
import type { MemoryEntry, MemoryStore, MemoryStoreConfig, SearchOptions } from '../../memory/types.js'
import type { ExtractionConfig } from '../../memory/extraction/types.js'
import type { JSONValue } from '../../types/json.js'
import { logger } from '../../logging/logger.js'
@@ -157,6 +158,7 @@ export class BedrockKnowledgeBaseStore implements MemoryStore {
readonly description?: string
readonly maxSearchResults?: number
readonly writable: boolean
readonly extraction?: boolean | ExtractionConfig
private readonly _runtimeClient: BedrockAgentRuntimeClient
private _agentClient: BedrockAgentClient | undefined
@@ -182,7 +184,7 @@ export class BedrockKnowledgeBaseStore implements MemoryStore {
public readonly filter: RetrievalFilter | undefined
constructor(options: BedrockKnowledgeBaseStoreConfig) {
const { config, scope, name, description, writable, maxSearchResults, filter } = options
const { config, scope, name, description, writable, maxSearchResults, filter, extraction } = options
this.name = name
if (description !== undefined) this.description = description
@@ -193,6 +195,7 @@ export class BedrockKnowledgeBaseStore implements MemoryStore {
this.maxSearchResults = maxSearchResults
}
this.writable = writable ?? false
if (extraction !== undefined) this.extraction = extraction
this._runtimeClient = config.runtimeClient ?? new BedrockAgentRuntimeClient({})
this._agentClient = config.agentClient