mirror of
https://github.com/JackChen-me/open-multi-agent.git
synced 2026-10-22 02:53:26 -05:00
* feat(memory): turn-count TTL on SharedMemory entries (chef-port)
Adds optional turn-count expiry to shared-memory entries so agents can
say "remember this for the next 3 turns then forget" without manual
cleanup. Ported from context-chef's `Memory.set({ ttl })` /
`advanceTurn()` design — same algorithm (`expiresAtTurn = currentTurn +
ttl`, lazy filter on read), narrowed to turns only (no ms-TTL — edge
case for chef, no clear OMA caller).
API additions (all backward compatible):
- `MemoryEntry.expiresAtTurn?: number` — optional field
- `MemoryStore.setWithExpiry?(...)` — optional method; stores that don't
implement it silently lose TTL semantics (writeExpiring degrades to
plain set)
- `SharedMemory.advanceTurn()` — increments the internal counter
- `SharedMemory.getTurnCount()` — reads the counter (observability)
- `SharedMemory.writeExpiring(agent, key, value, ttlTurns, metadata?)`
— parallel to write(), tags entry with computed expiresAtTurn
Reads (`read`, `listAll`, `listByAgent`, `getSummary`) all filter
expired entries and delete them as a side effect (lazy cleanup, no
background timer).
Orchestrator integration: `runTeam` / `runTasks` advance the shared
memory turn counter once per completed task (right after the task
result is written to memory). Standalone `runAgent` does not advance
— there is no team turn boundary in single-agent runs.
+5 tests covering: expiry happens at the right boundary; entries
without TTL persist forever; all read paths filter expired; custom
stores without `setWithExpiry` degrade gracefully; turn counter
observability.
788/788 passing. Lint clean.
Custom MemoryStore implementations (Redis, SQLite, etc.) require zero
changes — the new optional method means turn-TTL is opt-in. Backends
that want it implement `setWithExpiry`; the rest continue to work
unchanged but won't honour TTL.
* docs(memory): disclose parallel-batch turn-counter semantics on writeExpiring
Self-review caught a subtle case: in parallel batch execution, the turn
counter advances per *completed* task, not per *invoked* task. So if
task A writes a TTL entry while task B is still running, B completing
first will advance the counter and may expire A's entry sooner than
naive wall-clock intuition suggests.
Add a @remarks block to writeExpiring's JSDoc so callers know to use
write + explicit delete for cross-task hand-offs that need stricter
ordering, rather than relying on TTL.
* fix(memory): address review feedback on PR #213
Two real issues caught in review:
@JackChen-me — race in custom stores:
read() / filterExpired() previously deleted expired entries as a side
effect of read. In distributed backends (Redis/Postgres) this races
with concurrent writers — a fresh setWithExpiry() between our get and
our delete would have its value destroyed.
Fix: SharedMemory only filters expired entries from views; store impls
own their own GC (Redis has native EXPIRE; Postgres a cron). The
in-memory store accepts unbounded growth in pathological cases —
acceptable trade-off given typical agent-run scale, and matches the
pattern of "the store decides retention".
Also documented the new contract in read() and filterExpired() JSDoc.
@CodingBangboo — API guard on ttlTurns:
Previous JSDoc was self-contradictory ("Must be a positive integer; 0
means already expired"). Either is a real footgun: ttlTurns = 0 is
dead-on-arrival, ttlTurns = -1 / NaN / 1.5 silently behave weirdly.
Fix: throw RangeError on `!Number.isInteger(ttlTurns) || ttlTurns < 1`.
Use write() (no TTL) for never-expiring entries.
Tests:
- New: writeExpiring throws on 0, -1, 1.5, NaN, Infinity
- New regression guard: expired entries remain in underlying store
after read (proves no destructive cleanup)
790/790 passing. Lint clean.
408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { SharedMemory } from '../src/memory/shared.js'
|
|
import { Team } from '../src/team/team.js'
|
|
import type { MemoryEntry, MemoryStore } from '../src/types.js'
|
|
|
|
describe('SharedMemory', () => {
|
|
// -------------------------------------------------------------------------
|
|
// Write & read
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('writes and reads a value under a namespaced key', async () => {
|
|
const mem = new SharedMemory()
|
|
await mem.write('researcher', 'findings', 'TS 5.5 ships const type params')
|
|
|
|
const entry = await mem.read('researcher/findings')
|
|
expect(entry).not.toBeNull()
|
|
expect(entry!.value).toBe('TS 5.5 ships const type params')
|
|
})
|
|
|
|
it('returns null for a non-existent key', async () => {
|
|
const mem = new SharedMemory()
|
|
expect(await mem.read('nope/nothing')).toBeNull()
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Namespace isolation
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('isolates writes between agents', async () => {
|
|
const mem = new SharedMemory()
|
|
await mem.write('alice', 'plan', 'plan A')
|
|
await mem.write('bob', 'plan', 'plan B')
|
|
|
|
const alice = await mem.read('alice/plan')
|
|
const bob = await mem.read('bob/plan')
|
|
expect(alice!.value).toBe('plan A')
|
|
expect(bob!.value).toBe('plan B')
|
|
})
|
|
|
|
it('listByAgent returns only that agent\'s entries', async () => {
|
|
const mem = new SharedMemory()
|
|
await mem.write('alice', 'a1', 'v1')
|
|
await mem.write('alice', 'a2', 'v2')
|
|
await mem.write('bob', 'b1', 'v3')
|
|
|
|
const aliceEntries = await mem.listByAgent('alice')
|
|
expect(aliceEntries).toHaveLength(2)
|
|
expect(aliceEntries.every((e) => e.key.startsWith('alice/'))).toBe(true)
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Overwrite
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('overwrites a value and preserves createdAt', async () => {
|
|
const mem = new SharedMemory()
|
|
await mem.write('agent', 'key', 'first')
|
|
const first = await mem.read('agent/key')
|
|
|
|
await mem.write('agent', 'key', 'second')
|
|
const second = await mem.read('agent/key')
|
|
|
|
expect(second!.value).toBe('second')
|
|
expect(second!.createdAt.getTime()).toBe(first!.createdAt.getTime())
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Metadata
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('stores metadata alongside the value', async () => {
|
|
const mem = new SharedMemory()
|
|
await mem.write('agent', 'key', 'val', { priority: 'high' })
|
|
|
|
const entry = await mem.read('agent/key')
|
|
expect(entry!.metadata).toMatchObject({ priority: 'high', agent: 'agent' })
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Summary
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('returns empty string for an empty store', async () => {
|
|
const mem = new SharedMemory()
|
|
expect(await mem.getSummary()).toBe('')
|
|
})
|
|
|
|
it('produces a markdown summary grouped by agent', async () => {
|
|
const mem = new SharedMemory()
|
|
await mem.write('researcher', 'findings', 'result A')
|
|
await mem.write('coder', 'plan', 'implement X')
|
|
|
|
const summary = await mem.getSummary()
|
|
expect(summary).toContain('## Shared Team Memory')
|
|
expect(summary).toContain('### researcher')
|
|
expect(summary).toContain('### coder')
|
|
expect(summary).toContain('findings: result A')
|
|
expect(summary).toContain('plan: implement X')
|
|
})
|
|
|
|
it('truncates long values in the summary', async () => {
|
|
const mem = new SharedMemory()
|
|
const longValue = 'x'.repeat(300)
|
|
await mem.write('agent', 'big', longValue)
|
|
|
|
const summary = await mem.getSummary()
|
|
// Summary truncates at 200 chars → 197 + '…'
|
|
expect(summary.length).toBeLessThan(longValue.length)
|
|
expect(summary).toContain('…')
|
|
})
|
|
|
|
it('filters summary to only requested task IDs', async () => {
|
|
const mem = new SharedMemory()
|
|
await mem.write('alice', 'task:t1:result', 'output 1')
|
|
await mem.write('bob', 'task:t2:result', 'output 2')
|
|
await mem.write('alice', 'notes', 'not a task result')
|
|
|
|
const summary = await mem.getSummary({ taskIds: ['t2'] })
|
|
expect(summary).toContain('### bob')
|
|
expect(summary).toContain('task:t2:result: output 2')
|
|
expect(summary).not.toContain('task:t1:result: output 1')
|
|
expect(summary).not.toContain('notes: not a task result')
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// listAll
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('listAll returns entries from all agents', async () => {
|
|
const mem = new SharedMemory()
|
|
await mem.write('a', 'k1', 'v1')
|
|
await mem.write('b', 'k2', 'v2')
|
|
|
|
const all = await mem.listAll()
|
|
expect(all).toHaveLength(2)
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Custom MemoryStore injection (issue #156)
|
|
// -------------------------------------------------------------------------
|
|
|
|
describe('custom MemoryStore injection', () => {
|
|
/** Recording store that forwards to an internal map and tracks every call. */
|
|
class RecordingStore implements MemoryStore {
|
|
readonly data = new Map<string, MemoryEntry>()
|
|
readonly setCalls: Array<{ key: string; value: string }> = []
|
|
|
|
async get(key: string): Promise<MemoryEntry | null> {
|
|
return this.data.get(key) ?? null
|
|
}
|
|
async set(
|
|
key: string,
|
|
value: string,
|
|
metadata?: Record<string, unknown>,
|
|
): Promise<void> {
|
|
this.setCalls.push({ key, value })
|
|
this.data.set(key, { key, value, metadata, createdAt: new Date() })
|
|
}
|
|
async list(): Promise<MemoryEntry[]> {
|
|
return Array.from(this.data.values())
|
|
}
|
|
async delete(key: string): Promise<void> {
|
|
this.data.delete(key)
|
|
}
|
|
async clear(): Promise<void> {
|
|
this.data.clear()
|
|
}
|
|
}
|
|
|
|
it('routes writes through an injected MemoryStore', async () => {
|
|
const store = new RecordingStore()
|
|
const mem = new SharedMemory(store)
|
|
await mem.write('alice', 'plan', 'v1')
|
|
|
|
expect(store.setCalls).toEqual([{ key: 'alice/plan', value: 'v1' }])
|
|
})
|
|
|
|
it('preserves `<agent>/<key>` namespace prefix on the underlying store', async () => {
|
|
const store = new RecordingStore()
|
|
const mem = new SharedMemory(store)
|
|
await mem.write('bob', 'notes', 'hello')
|
|
|
|
const entry = await store.get('bob/notes')
|
|
expect(entry?.value).toBe('hello')
|
|
})
|
|
|
|
it('getSummary reads from the injected store', async () => {
|
|
const store = new RecordingStore()
|
|
const mem = new SharedMemory(store)
|
|
await mem.write('alice', 'k', 'val')
|
|
|
|
const summary = await mem.getSummary()
|
|
expect(summary).toContain('### alice')
|
|
expect(summary).toContain('k: val')
|
|
})
|
|
|
|
it('getStore returns the injected store', () => {
|
|
const store = new RecordingStore()
|
|
const mem = new SharedMemory(store)
|
|
expect(mem.getStore()).toBe(store)
|
|
})
|
|
|
|
it('Team wires `sharedMemoryStore` into its SharedMemory', async () => {
|
|
const store = new RecordingStore()
|
|
const team = new Team({
|
|
name: 'injection-team',
|
|
agents: [{ name: 'alice', model: 'claude-sonnet-4-6' }],
|
|
sharedMemoryStore: store,
|
|
})
|
|
|
|
const sharedMem = team.getSharedMemoryInstance()
|
|
expect(sharedMem).toBeDefined()
|
|
await sharedMem!.write('alice', 'fact', 'committed')
|
|
|
|
expect(store.setCalls).toEqual([{ key: 'alice/fact', value: 'committed' }])
|
|
})
|
|
|
|
it('Team: `sharedMemoryStore` takes precedence over `sharedMemory: false`', () => {
|
|
const store = new RecordingStore()
|
|
const team = new Team({
|
|
name: 'override-team',
|
|
agents: [{ name: 'alice', model: 'claude-sonnet-4-6' }],
|
|
sharedMemory: false,
|
|
sharedMemoryStore: store,
|
|
})
|
|
|
|
// Custom store wins: memory is enabled even though the boolean is false.
|
|
expect(team.getSharedMemoryInstance()).toBeDefined()
|
|
expect(team.getSharedMemory()).toBe(store)
|
|
})
|
|
|
|
it('Team: neither flag → no shared memory (backward compat)', () => {
|
|
const team = new Team({
|
|
name: 'no-memory-team',
|
|
agents: [{ name: 'alice', model: 'claude-sonnet-4-6' }],
|
|
})
|
|
expect(team.getSharedMemoryInstance()).toBeUndefined()
|
|
})
|
|
|
|
it('Team: `sharedMemory: true` only → default InMemoryStore (backward compat)', () => {
|
|
const team = new Team({
|
|
name: 'default-memory-team',
|
|
agents: [{ name: 'alice', model: 'claude-sonnet-4-6' }],
|
|
sharedMemory: true,
|
|
})
|
|
expect(team.getSharedMemoryInstance()).toBeDefined()
|
|
expect(team.getSharedMemory()).toBeDefined()
|
|
})
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Shape validation — defends against malformed `sharedMemoryStore`
|
|
// (e.g. plain objects from untrusted JSON) reaching SharedMemory.
|
|
// -----------------------------------------------------------------------
|
|
|
|
it('SharedMemory throws when store is a plain object missing methods', () => {
|
|
const plain = { foo: 'bar' } as unknown as MemoryStore
|
|
expect(() => new SharedMemory(plain)).toThrow(TypeError)
|
|
expect(() => new SharedMemory(plain)).toThrow(/MemoryStore interface/)
|
|
})
|
|
|
|
it('SharedMemory throws when store is missing a single method', () => {
|
|
const partial = {
|
|
get: async () => null,
|
|
set: async () => undefined,
|
|
list: async () => [],
|
|
delete: async () => undefined,
|
|
// `clear` missing
|
|
} as unknown as MemoryStore
|
|
expect(() => new SharedMemory(partial)).toThrow(TypeError)
|
|
})
|
|
|
|
it('SharedMemory throws when store is null (cast)', () => {
|
|
expect(() => new SharedMemory(null as unknown as MemoryStore)).toThrow(TypeError)
|
|
})
|
|
|
|
it('Team throws early on malformed `sharedMemoryStore`', () => {
|
|
const bogus = { not: 'a store' } as unknown as MemoryStore
|
|
expect(
|
|
() =>
|
|
new Team({
|
|
name: 'bad-team',
|
|
agents: [{ name: 'alice', model: 'claude-sonnet-4-6' }],
|
|
sharedMemoryStore: bogus,
|
|
}),
|
|
).toThrow(TypeError)
|
|
})
|
|
|
|
it('Team throws on falsy-but-present sharedMemoryStore (null)', () => {
|
|
// `null` is falsy but present; a truthy gate would silently drop it.
|
|
// The `!== undefined` gate routes it through SharedMemory's shape check
|
|
// so config bugs fail fast instead of being silently downgraded.
|
|
expect(
|
|
() =>
|
|
new Team({
|
|
name: 'null-store-team',
|
|
agents: [{ name: 'alice', model: 'claude-sonnet-4-6' }],
|
|
sharedMemoryStore: null as unknown as MemoryStore,
|
|
}),
|
|
).toThrow(TypeError)
|
|
})
|
|
|
|
it('Team: omitting sharedMemoryStore entirely still honors sharedMemory: true', () => {
|
|
// Sanity check that the `!== undefined` gate does not accidentally
|
|
// enable memory when the field is absent.
|
|
const team = new Team({
|
|
name: 'absent-store-team',
|
|
agents: [{ name: 'alice', model: 'claude-sonnet-4-6' }],
|
|
sharedMemory: true,
|
|
})
|
|
expect(team.getSharedMemoryInstance()).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('turn-TTL (writeExpiring + advanceTurn)', () => {
|
|
it('writeExpiring entries are readable until the turn counter reaches expiry', async () => {
|
|
const mem = new SharedMemory()
|
|
await mem.writeExpiring('alice', 'short-lived', 'still here', 2)
|
|
|
|
// turn 0: readable
|
|
expect(await mem.read('alice/short-lived')).not.toBeNull()
|
|
mem.advanceTurn()
|
|
// turn 1: readable
|
|
expect(await mem.read('alice/short-lived')).not.toBeNull()
|
|
mem.advanceTurn()
|
|
// turn 2: expired (currentTurn 2 >= expiresAtTurn 2)
|
|
expect(await mem.read('alice/short-lived')).toBeNull()
|
|
})
|
|
|
|
it('write (no TTL) entries persist regardless of turn count', async () => {
|
|
const mem = new SharedMemory()
|
|
await mem.write('alice', 'permanent', 'forever')
|
|
for (let i = 0; i < 100; i++) mem.advanceTurn()
|
|
expect(await mem.read('alice/permanent')).not.toBeNull()
|
|
})
|
|
|
|
it('listAll / listByAgent / getSummary all filter expired entries', async () => {
|
|
const mem = new SharedMemory()
|
|
await mem.write('alice', 'kept', 'alive')
|
|
await mem.writeExpiring('alice', 'dropped', 'gone', 1)
|
|
mem.advanceTurn() // expires the second entry
|
|
|
|
expect(await mem.listAll()).toHaveLength(1)
|
|
expect(await mem.listByAgent('alice')).toHaveLength(1)
|
|
const summary = await mem.getSummary()
|
|
expect(summary).toContain('kept')
|
|
expect(summary).not.toContain('dropped')
|
|
})
|
|
|
|
it('writeExpiring degrades to plain set when the store lacks setWithExpiry', async () => {
|
|
// Custom stores satisfying only the required MemoryStore methods (no
|
|
// setWithExpiry) still work — the entry persists indefinitely instead
|
|
// of expiring. Documented behaviour, exercised here.
|
|
const data = new Map<string, MemoryEntry>()
|
|
const customStore: MemoryStore = {
|
|
async get(key) { return data.get(key) ?? null },
|
|
async set(key, value, metadata) {
|
|
data.set(key, { key, value, metadata, createdAt: new Date() })
|
|
},
|
|
async list() { return Array.from(data.values()) },
|
|
async delete(key) { data.delete(key) },
|
|
async clear() { data.clear() },
|
|
}
|
|
const mem = new SharedMemory(customStore)
|
|
await mem.writeExpiring('alice', 'ttl-ignored', 'still here', 1)
|
|
mem.advanceTurn()
|
|
// Custom store didn't record expiry, so the entry is still readable.
|
|
expect(await mem.read('alice/ttl-ignored')).not.toBeNull()
|
|
})
|
|
|
|
it('getTurnCount reflects advanceTurn calls', async () => {
|
|
const mem = new SharedMemory()
|
|
expect(mem.getTurnCount()).toBe(0)
|
|
mem.advanceTurn()
|
|
mem.advanceTurn()
|
|
mem.advanceTurn()
|
|
expect(mem.getTurnCount()).toBe(3)
|
|
})
|
|
|
|
it('writeExpiring throws RangeError on non-positive-integer ttlTurns', async () => {
|
|
const mem = new SharedMemory()
|
|
for (const bad of [0, -1, 1.5, NaN, Infinity]) {
|
|
await expect(
|
|
mem.writeExpiring('alice', 'k', 'v', bad),
|
|
).rejects.toThrow(RangeError)
|
|
}
|
|
})
|
|
|
|
it('expired entries remain in the underlying store (no destructive cleanup on read)', async () => {
|
|
// Race-safe: in distributed stores (Redis/Postgres), deleting on read
|
|
// would stomp on a concurrent write. SharedMemory only filters; store
|
|
// impls do their own GC. Regression guard for the original review.
|
|
const mem = new SharedMemory()
|
|
const store = mem.getStore()
|
|
await mem.writeExpiring('alice', 'gone', 'soon', 1)
|
|
mem.advanceTurn() // expires it
|
|
|
|
// SharedMemory hides the expired entry from callers...
|
|
expect(await mem.read('alice/gone')).toBeNull()
|
|
expect(await mem.listAll()).toHaveLength(0)
|
|
expect(await mem.getSummary()).toBe('')
|
|
|
|
// ...but the underlying store still has it (didn't get deleted).
|
|
expect(await store.get('alice/gone')).not.toBeNull()
|
|
expect(await store.list()).toHaveLength(1)
|
|
})
|
|
})
|
|
})
|