Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/ai-sdk-cache-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@gemstack/ai-sdk": minor
---

Decouple the cache-backed run stores from `@rudderjs/cache` (epic: framework-agnostic engine).

`CachedAgentRunStore` and `CachedSubAgentRunStore` no longer lazy-import `@rudderjs/cache` or read a global `CacheRegistry`. They now take a required, caller-supplied cache via `{ cache }`, typed against the new exported `CacheAdapter` contract (a 3-method interface: `get` / `set` / `forget`). Bring any cache (redis, Memcached, a `Map`, a framework's cache).

**Breaking (0.x):**
- `new CachedAgentRunStore()` / `new CachedSubAgentRunStore()` with no cache now throw; pass `{ cache }`. (All in-repo and documented usage already passes it.)
- Default key prefixes changed from `rudderjs:ai:*` to `gemstack:ai:*`. These guard 5-minute-TTL ephemeral run snapshots, so the only effect on upgrade is that in-flight parked runs fall back to "not found" once (they self-heal). Override `keyPrefix` to keep the old value if needed.

Also made the Google prompt-cache registry fully neutral (it already accepted a BYO store): it now uses the shared `CacheAdapter` type and a `gemstack:ai:google-cache:` key prefix, with no `@rudderjs/cache` references.
12 changes: 8 additions & 4 deletions packages/ai-sdk/src/agent-run-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ describe('CachedAgentRunStore', () => {
const store = new CachedAgentRunStore({ cache })
await store.store('xyz', clientToolState())

assert.ok(cache.map.has('rudderjs:ai:agent-run:xyz'))
assert.equal(cache.ttls['rudderjs:ai:agent-run:xyz'], 5 * 60)
assert.ok(cache.map.has('gemstack:ai:agent-run:xyz'))
assert.equal(cache.ttls['gemstack:ai:agent-run:xyz'], 5 * 60)
})

it('honors a custom keyPrefix and ttlSeconds', async () => {
Expand All @@ -173,10 +173,10 @@ describe('CachedAgentRunStore', () => {
await store.store('k', clientToolState())

assert.ok(await store.load('k'))
assert.ok(cache.map.has('rudderjs:ai:agent-run:k')) // still there
assert.ok(cache.map.has('gemstack:ai:agent-run:k')) // still there

assert.ok(await store.consume('k'))
assert.equal(cache.map.has('rudderjs:ai:agent-run:k'), false) // gone
assert.equal(cache.map.has('gemstack:ai:agent-run:k'), false) // gone
assert.equal(await store.consume('k'), null)
})

Expand All @@ -185,4 +185,8 @@ describe('CachedAgentRunStore', () => {
assert.equal(await store.load('missing'), null)
assert.equal(await store.consume('missing'), null)
})

it('throws when constructed without a cache adapter', () => {
assert.throws(() => new CachedAgentRunStore({} as never), /requires a cache adapter/)
})
})
89 changes: 28 additions & 61 deletions packages/ai-sdk/src/agent-run-store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AiMessage, ToolCall } from './types.js'
import type { CacheAdapter } from './cache-adapter.js'

/**
* Discriminator for the kind of pause a standalone run is parked on.
Expand Down Expand Up @@ -66,9 +67,9 @@ export interface AgentRunState {
* - {@link InMemoryAgentRunStore} — a `Map`-backed store. Single-process only;
* fine for unit tests and small dev setups, lossy across worker processes and
* restarts.
* - {@link CachedAgentRunStore} — lazy adapter on top of `@rudderjs/cache`.
* Cross-process / cross-restart when the cache is configured with redis or any
* non-memory driver.
* - {@link CachedAgentRunStore} — adapter over any {@link CacheAdapter} you
* supply. Cross-process / cross-restart when that cache is backed by redis or
* any non-memory driver.
*
* Hosts may implement their own (Redis directly, Prisma, etc.) by satisfying
* this interface.
Expand Down Expand Up @@ -112,7 +113,8 @@ export function newAgentRunId(): string {
/**
* `Map`-backed implementation suitable for tests and single-process dev.
* Loses state across restarts and worker processes — for any multi-worker
* deployment, use {@link CachedAgentRunStore} or a custom backend.
* deployment, use {@link CachedAgentRunStore} with a shared cache, or a custom
* backend.
*/
export class InMemoryAgentRunStore implements AgentRunStore {
private readonly states = new Map<string, AgentRunState>()
Expand All @@ -138,92 +140,57 @@ export class InMemoryAgentRunStore implements AgentRunStore {
}
}

// ─── @rudderjs/cache adapter ───────────────────────────────

/**
* Minimal structural shape of a cache adapter (the methods this store touches).
* Mirrors `@rudderjs/cache`'s `CacheAdapter` so the dep stays structural — the
* framework's main entry stays runtime-agnostic.
*/
interface CacheStoreLike {
get<T = unknown>(key: string): Promise<T | null>
set(key: string, value: unknown, ttlSeconds?: number): Promise<void>
forget(key: string): Promise<void>
}
// ─── Cache-backed store (bring your own CacheAdapter) ───────

export interface CachedAgentRunStoreOptions {
/**
* Cache adapter to use. When omitted, the store loads `@rudderjs/cache`
* lazily and falls back to the registered global adapter
* (`CacheRegistry.get()`); throws if neither resolves.
* The cache to persist runs in. Supply any {@link CacheAdapter} (redis,
* Memcached, a `Map`, a framework's cache). Required — `@gemstack/ai-sdk`
* bundles no cache implementation.
*/
cache?: CacheStoreLike
/** Key namespace prefix. Default `'rudderjs:ai:agent-run:'`. */
cache: CacheAdapter
/** Key namespace prefix. Default `'gemstack:ai:agent-run:'`. */
keyPrefix?: string
/** Time-to-live in seconds. Default 5 minutes. */
ttlSeconds?: number
}

/**
* Standalone agent run store backed by `@rudderjs/cache`. Loads the cache
* adapter lazily so `@gemstack/ai-sdk`'s main entry stays runtime-agnostic (no
* static import on the cache package).
* Standalone agent run store backed by a caller-supplied {@link CacheAdapter}.
* The framework depends on no cache package; you bring the cache and pass it as
* `{ cache }`.
*
* Default TTL is 5 minutes — long enough for a browser to round-trip a few
* client tool calls or an approval decision, short enough that abandoned runs
* garbage-collect promptly and the storage bill stays bounded.
*/
export class CachedAgentRunStore implements AgentRunStore {
private readonly explicitCache?: CacheStoreLike
private readonly keyPrefix: string
private readonly ttlSeconds: number
private resolvedCache?: CacheStoreLike

constructor(opts: CachedAgentRunStoreOptions = {}) {
if (opts.cache) this.explicitCache = opts.cache
this.keyPrefix = opts.keyPrefix ?? 'rudderjs:ai:agent-run:'
this.ttlSeconds = opts.ttlSeconds ?? 5 * 60
}
private readonly cache: CacheAdapter
private readonly keyPrefix: string
private readonly ttlSeconds: number

private async getCache(): Promise<CacheStoreLike> {
if (this.resolvedCache) return this.resolvedCache
if (this.explicitCache) {
this.resolvedCache = this.explicitCache
return this.resolvedCache
}
// Lazy-import @rudderjs/cache and ask the registry for the active adapter.
// This keeps the static import surface zero — the import only fires when the
// host actually opts into suspendable standalone runs. We dodge static
// module-resolution by using an indirected specifier so `@rudderjs/cache`
// doesn't need to be a declared dep of `@gemstack/ai-sdk` (optional runtime peer).
const cacheSpecifier = '@rudderjs/cache'
const mod = await import(/* @vite-ignore */ cacheSpecifier) as {
CacheRegistry?: { get(): CacheStoreLike | null }
constructor(opts: CachedAgentRunStoreOptions) {
if (!opts?.cache) {
throw new Error('[ai-sdk] CachedAgentRunStore requires a cache adapter: new CachedAgentRunStore({ cache }).')
}
const adapter = mod.CacheRegistry?.get?.()
if (!adapter) {
throw new Error('[ai-sdk] CachedAgentRunStore needs a cache adapter. Install `@rudderjs/cache`, register a driver, or pass `{ cache }` explicitly.')
}
this.resolvedCache = adapter
return adapter
this.cache = opts.cache
this.keyPrefix = opts.keyPrefix ?? 'gemstack:ai:agent-run:'
this.ttlSeconds = opts.ttlSeconds ?? 5 * 60
}

async store(runId: string, state: AgentRunState): Promise<void> {
const cache = await this.getCache()
await cache.set(this.keyPrefix + runId, state, this.ttlSeconds)
await this.cache.set(this.keyPrefix + runId, state, this.ttlSeconds)
}

async load(runId: string): Promise<AgentRunState | null> {
const cache = await this.getCache()
return (await cache.get<AgentRunState>(this.keyPrefix + runId)) ?? null
return (await this.cache.get<AgentRunState>(this.keyPrefix + runId)) ?? null
}

async consume(runId: string): Promise<AgentRunState | null> {
const cache = await this.getCache()
const key = this.keyPrefix + runId
const state = await cache.get<AgentRunState>(key)
const state = await this.cache.get<AgentRunState>(key)
if (!state) return null
await cache.forget(key)
await this.cache.forget(key)
return state
}
}
25 changes: 25 additions & 0 deletions packages/ai-sdk/src/cache-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Neutral cache contract the run stores persist through.
*
* `@gemstack/ai-sdk` does not bundle or depend on any cache implementation.
* Implement this small interface against whatever cache you run (Redis,
* Memcached, a `Map`, a framework's cache layer) and pass it to
* {@link CachedAgentRunStore} / {@link CachedSubAgentRunStore} via `{ cache }`.
*
* ```ts
* const cache: CacheAdapter = {
* async get(key) { return JSON.parse((await redis.get(key)) ?? 'null') },
* async set(key, value, ttl) { await redis.set(key, JSON.stringify(value), ttl ? { EX: ttl } : undefined) },
* async forget(key) { await redis.del(key) },
* }
* const store = new CachedAgentRunStore({ cache })
* ```
*/
export interface CacheAdapter {
/** Read a value by key, or `null` when absent/expired. */
get<T = unknown>(key: string): Promise<T | null>
/** Write a value, optionally with a TTL in seconds. */
set(key: string, value: unknown, ttlSeconds?: number): Promise<void>
/** Delete a value by key. */
forget(key: string): Promise<void>
}
3 changes: 3 additions & 0 deletions packages/ai-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ export type { MemoryInjectOptions } from './memory-inject.js'
export { withMemoryExtract } from './memory-extract.js'
export type { MemoryExtractOptions } from './memory-extract.js'

// Neutral cache contract for the cache-backed run stores (bring your own)
export type { CacheAdapter } from './cache-adapter.js'

// Sub-agent run store (asTool streaming + suspend)
export {
InMemorySubAgentRunStore,
Expand Down
28 changes: 11 additions & 17 deletions packages/ai-sdk/src/providers/google-cache-registry.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
import { cyrb53Hex } from '../util/hash.js'
import type { CacheAdapter } from '../cache-adapter.js'

/**
* Minimal structural shape of a cache store the registry can use. Matches
* `@rudderjs/cache`'s `CacheAdapter` (see `packages/cache/src/index.ts`)
* for the methods we touch. Defined locally so this file (which lives in
* the runtime-agnostic main entry) doesn't take a cross-package dep — the
* AiProvider hands in the real adapter at boot.
* The cache store the registry can use. This is the framework's neutral
* {@link CacheAdapter} — the AiProvider hands in a real adapter at boot, and
* the registry falls back to an in-process `Map` when none is supplied.
*/
export interface CacheStoreLike {
get<T = unknown>(key: string): Promise<T | null>
set(key: string, value: unknown, ttlSeconds?: number): Promise<void>
forget(key: string): Promise<void>
}
export type CacheStoreLike = CacheAdapter

interface CacheEntry { name: string; expiresAt: number }
interface TooSmallEntry { tooSmall: true; expiresAt: number }
type StoredEntry = CacheEntry | TooSmallEntry

const TOO_SMALL_TTL_MS = 5 * 60 * 1000 // 5 minutes — Q4 in plan
const KEY_PREFIX = 'rudderjs:ai:google-cache:'
const KEY_PREFIX = 'gemstack:ai:google-cache:'

export interface GoogleCacheRegistryOptions {
/** Optional cache backend (cross-process / cross-restart). Falls back to in-process Map. */
Expand Down Expand Up @@ -68,11 +63,10 @@ export interface GoogleClientLike {
* provider. Coordinates concurrent creates, memoizes "too-small" failures,
* and drops stale entries on demand (so the adapter can recreate-on-404).
*
* Storage is pluggable: the AiProvider passes a `CacheStoreLike` (typically
* `@rudderjs/cache`'s adapter) when available, otherwise the registry uses
* an in-process `Map` and warns once. Either way, in-process locking keeps
* concurrent same-key requests from racing on `caches.create` within one
* worker.
* Storage is pluggable: the AiProvider passes a {@link CacheAdapter} when one
* is available, otherwise the registry uses an in-process `Map` and warns once.
* Either way, in-process locking keeps concurrent same-key requests from racing
* on `caches.create` within one worker.
*/
export class GoogleCacheRegistry {
private readonly store?: CacheStoreLike
Expand Down Expand Up @@ -168,7 +162,7 @@ export class GoogleCacheRegistry {
this.warnedNoStore = true
console.warn(
'[ai-sdk] Google prompt caching is using in-memory storage; ' +
'install @rudderjs/cache for cross-process/restart persistence.',
'pass a CacheAdapter (`{ store }`) for cross-process/restart persistence.',
)
}
const entry = this.memory.get(storeKey)
Expand Down
8 changes: 6 additions & 2 deletions packages/ai-sdk/src/sub-agent-run-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ describe('CachedSubAgentRunStore.load', () => {
await store.store('k', clientToolSnapshot())

assert.ok(await store.load('k'))
assert.ok(cache.map.has('rudderjs:ai:sub-agent-run:k')) // still there after load
assert.ok(cache.map.has('gemstack:ai:sub-agent-run:k')) // still there after load

assert.ok(await store.consume('k'))
assert.equal(cache.map.has('rudderjs:ai:sub-agent-run:k'), false) // gone after consume
assert.equal(cache.map.has('gemstack:ai:sub-agent-run:k'), false) // gone after consume
})

it('returns null for an unknown id', async () => {
Expand All @@ -112,4 +112,8 @@ describe('CachedSubAgentRunStore.load', () => {
assert.ok(await store.load('1'))
assert.ok(cache.map.has('app:sub:1'))
})

it('throws when constructed without a cache adapter', () => {
assert.throws(() => new CachedSubAgentRunStore({} as never), /requires a cache adapter/)
})
})
Loading
Loading