mirror of
https://github.com/strands-agents/harness-sdk.git
synced 2026-10-02 02:44:48 +08:00
feat: add extraction defaults and support for bedrock kbs (#2719)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,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) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+59
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user