diff --git a/.changeset/ai-sdk-cache-seam.md b/.changeset/ai-sdk-cache-seam.md new file mode 100644 index 0000000..6685c40 --- /dev/null +++ b/.changeset/ai-sdk-cache-seam.md @@ -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. diff --git a/packages/ai-sdk/src/agent-run-store.test.ts b/packages/ai-sdk/src/agent-run-store.test.ts index 9829a71..f31e36e 100644 --- a/packages/ai-sdk/src/agent-run-store.test.ts +++ b/packages/ai-sdk/src/agent-run-store.test.ts @@ -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 () => { @@ -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) }) @@ -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/) + }) }) diff --git a/packages/ai-sdk/src/agent-run-store.ts b/packages/ai-sdk/src/agent-run-store.ts index 148bddc..9d77944 100644 --- a/packages/ai-sdk/src/agent-run-store.ts +++ b/packages/ai-sdk/src/agent-run-store.ts @@ -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. @@ -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. @@ -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() @@ -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(key: string): Promise - set(key: string, value: unknown, ttlSeconds?: number): Promise - forget(key: string): Promise -} +// ─── 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 { - 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 { - 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 { - const cache = await this.getCache() - return (await cache.get(this.keyPrefix + runId)) ?? null + return (await this.cache.get(this.keyPrefix + runId)) ?? null } async consume(runId: string): Promise { - const cache = await this.getCache() const key = this.keyPrefix + runId - const state = await cache.get(key) + const state = await this.cache.get(key) if (!state) return null - await cache.forget(key) + await this.cache.forget(key) return state } } diff --git a/packages/ai-sdk/src/cache-adapter.ts b/packages/ai-sdk/src/cache-adapter.ts new file mode 100644 index 0000000..9fce125 --- /dev/null +++ b/packages/ai-sdk/src/cache-adapter.ts @@ -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(key: string): Promise + /** Write a value, optionally with a TTL in seconds. */ + set(key: string, value: unknown, ttlSeconds?: number): Promise + /** Delete a value by key. */ + forget(key: string): Promise +} diff --git a/packages/ai-sdk/src/index.ts b/packages/ai-sdk/src/index.ts index dcdb10f..4c0f194 100644 --- a/packages/ai-sdk/src/index.ts +++ b/packages/ai-sdk/src/index.ts @@ -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, diff --git a/packages/ai-sdk/src/providers/google-cache-registry.ts b/packages/ai-sdk/src/providers/google-cache-registry.ts index 261c341..d01d2ce 100644 --- a/packages/ai-sdk/src/providers/google-cache-registry.ts +++ b/packages/ai-sdk/src/providers/google-cache-registry.ts @@ -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(key: string): Promise - set(key: string, value: unknown, ttlSeconds?: number): Promise - forget(key: string): Promise -} +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. */ @@ -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 @@ -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) diff --git a/packages/ai-sdk/src/sub-agent-run-store.test.ts b/packages/ai-sdk/src/sub-agent-run-store.test.ts index 1e9fd78..6dcd24b 100644 --- a/packages/ai-sdk/src/sub-agent-run-store.test.ts +++ b/packages/ai-sdk/src/sub-agent-run-store.test.ts @@ -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 () => { @@ -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/) + }) }) diff --git a/packages/ai-sdk/src/sub-agent-run-store.ts b/packages/ai-sdk/src/sub-agent-run-store.ts index c199544..9d29752 100644 --- a/packages/ai-sdk/src/sub-agent-run-store.ts +++ b/packages/ai-sdk/src/sub-agent-run-store.ts @@ -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 snapshot represents. Determines @@ -69,8 +70,8 @@ export interface SubAgentRunSnapshot { * - {@link InMemorySubAgentRunStore} — a `Map`-backed store. Single-process * only; fine for unit tests and small dev setups, lossy across worker * processes and restarts. - * - {@link CachedSubAgentRunStore} — lazy adapter on top of `@rudderjs/cache`. - * Cross-process / cross-restart when the cache is configured with redis + * - {@link CachedSubAgentRunStore} — 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 @@ -130,93 +131,57 @@ export class InMemorySubAgentRunStore implements SubAgentRunStore { } } -// ─── @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(key: string): Promise - set(key: string, value: unknown, ttlSeconds?: number): Promise - forget(key: string): Promise -} +// ─── Cache-backed store (bring your own CacheAdapter) ─────── export interface CachedSubAgentRunStoreOptions { /** - * Cache adapter to use. When omitted, the registry tries to load - * `@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:sub-agent-run:'`. */ + cache: CacheAdapter + /** Key namespace prefix. Default `'gemstack:ai:sub-agent-run:'`. */ keyPrefix?: string /** Time-to-live in seconds. Default 5 minutes. */ ttlSeconds?: number } /** - * Sub-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). + * Sub-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, short enough that abandoned runs garbage-collect * promptly and the storage bill stays bounded. */ export class CachedSubAgentRunStore implements SubAgentRunStore { - private readonly explicitCache?: CacheStoreLike - private readonly keyPrefix: string - private readonly ttlSeconds: number - private resolvedCache?: CacheStoreLike - - constructor(opts: CachedSubAgentRunStoreOptions = {}) { - if (opts.cache) this.explicitCache = opts.cache - this.keyPrefix = opts.keyPrefix ?? 'rudderjs:ai:sub-agent-run:' - this.ttlSeconds = opts.ttlSeconds ?? 5 * 60 - } + private readonly cache: CacheAdapter + private readonly keyPrefix: string + private readonly ttlSeconds: number - private async getCache(): Promise { - 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 sub-agents. - // 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` (it stays an optional runtime peer). - const cacheSpecifier = '@rudderjs/cache' - const mod = await import(/* @vite-ignore */ cacheSpecifier) as { - CacheRegistry?: { get(): CacheStoreLike | null } + constructor(opts: CachedSubAgentRunStoreOptions) { + if (!opts?.cache) { + throw new Error('[ai-sdk] CachedSubAgentRunStore requires a cache adapter: new CachedSubAgentRunStore({ cache }).') } - const adapter = mod.CacheRegistry?.get?.() - if (!adapter) { - throw new Error('[ai-sdk] CachedSubAgentRunStore 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:sub-agent-run:' + this.ttlSeconds = opts.ttlSeconds ?? 5 * 60 } async store(subRunId: string, snapshot: SubAgentRunSnapshot): Promise { - const cache = await this.getCache() - await cache.set(this.keyPrefix + subRunId, snapshot, this.ttlSeconds) + await this.cache.set(this.keyPrefix + subRunId, snapshot, this.ttlSeconds) } async consume(subRunId: string): Promise { - const cache = await this.getCache() const key = this.keyPrefix + subRunId - const snapshot = await cache.get(key) + const snapshot = await this.cache.get(key) if (!snapshot) return null - await cache.forget(key) + await this.cache.forget(key) return snapshot } async load(subRunId: string): Promise { - const cache = await this.getCache() - return cache.get(this.keyPrefix + subRunId) + return this.cache.get(this.keyPrefix + subRunId) } } diff --git a/packages/ai-sdk/src/types.ts b/packages/ai-sdk/src/types.ts index 7d2d39e..c4f1eca 100644 --- a/packages/ai-sdk/src/types.ts +++ b/packages/ai-sdk/src/types.ts @@ -160,8 +160,9 @@ export interface ProviderRequestOptions { * `prompt_cache_key` from a stable hash of the cached regions for routing * affinity (so repeat requests hit the same backend's cached prefix). * - **Google (Gemini)** — translates to `cachedContent` resources via a - * pluggable registry that uses `@rudderjs/cache` when installed. TTL is - * configurable via {@link CacheableConfig.ttl} (default `'1h'`). + * pluggable registry that uses a supplied `CacheAdapter` when available + * (else an in-process cache). TTL is configurable via + * {@link CacheableConfig.ttl} (default `'1h'`). * * Adapters that don't support caching ignore this field — the request * still runs uncached.