import { Redis } from '@upstash/redis'; import { MemoryDocumentBackend, MemoryDocumentReadInput, MemoryDocument, MemoryDocumentWriteInput } from 'eve/memory/file'; import { MemoryTurnCompletedContext, MemoryCompactionRequestedContext, MemoryTurnStartedContext, MemoryCompactionCompletedContext, MemoryProvider } from 'eve/memory'; /** * `redisDocuments()` — an Upstash Redis **storage backend** for eve's built-in `fileMemory()` * provider (`eve/memory/file`). Drop-in replacement for the default (Vercel Blob / in-memory) * backend, exactly like `vercelBlob()`: * * ```ts * // agent/memory/profile.ts * import { defineMemory } from "eve/memory"; * import { byPrincipal } from "eve/memory/scope"; * import { fileMemory } from "eve/memory/file"; * import { redisDocuments } from "@upstash/agentkit-eve/memory"; * * export default defineMemory({ * description: "Remember stable facts and preferences about the caller.", * provider: fileMemory({ backend: redisDocuments() }), * scope: byPrincipal, * }); * ``` * * This closes eve's documented gap: with no `backend`, `fileMemory()` resolves to in-memory storage * under `eve dev`, to Vercel Blob on Vercel, and **errors everywhere else**. Recall behavior and the * `save_memory`/`remove_memory` tools are eve's own and unchanged — only the storage moves. * * See `./provider.ts` for the other integration, `redisMemory()`, and `./index.ts` for * how the two differ and which to pick. * * ## Optimistic concurrency without WATCH (verified, not assumed) * * `MemoryDocumentBackend.write()` is a conditional replace: it must throw eve's * `MemoryDocumentConflictError` when the caller's `expectedVersion` no longer matches the stored * one (`fileMemory()` catches it, re-reads, and retries up to 8 times). * * `MULTI` **is** available over Upstash's REST API — `redis.multi()` posts to a dedicated * `/multi-exec` endpoint and executes atomically (measured). It still cannot do this job: a * transaction queues its commands and hands back every result at `EXEC`, so nothing inside it can * branch on a value it just read — `multi().get(k).set(k, v).exec()` returns `["a", "OK"]`, the * `set` having run unconditionally. Making the write conditional is what `WATCH` is for, and * `WATCH` is the part REST genuinely lacks: the server rejects it outright with * `ERR Command "WATCH" is not allowed in REST`, because watching a key spans requests and REST * keeps no session between them. * * So the compare and the swap have to happen inside a single server-side command. * * That command is `EVAL`. **Verified live against an Upstash Redis instance** (2026-09, an * `upstash start-redis` database on the current REST API), not assumed: * - `EVAL` is accepted over the REST API and through `@upstash/redis`'s `redis.eval(script, keys, * args)`, including with auto-pipelining enabled (the default); * - a Lua table return (`{0, currentVersion}` / `{1, newVersion}`) round-trips as a JSON array, so * the script can report *why* it refused and what the current version is; * - `HGET`/`HSET`/`EXPIRE` inside the script behave normally, and `SCRIPT LOAD` works too. * * The script ({@link CAS_SCRIPT}) is sent with every write rather than cached as a SHA + `EVALSHA`: * it is ~300 bytes, writes are rare (one per `save_memory`/`remove_memory` call), and `EVALSHA` * would need a `NOSCRIPT` fallback path for no measurable gain. * * ## Storage layout * * One Redis **hash** per eve scope key at `agentkit:memoryFile:`, with two fields, * `content` and `version`. A hash (rather than a JSON string) keeps the Lua script trivial: it * compares one field and writes two. * * The stored `content` carries a short {@link CONTENT_MARKER} prefix, stripped on read. This is not * decoration: `@upstash/redis` **auto-deserializes** replies, so a document whose text happens to * be valid JSON (`123`, `{"a":1}`) comes back as a `number`/`object` instead of the exact string * that was written — measured, not theorized. The marker makes every stored value un-parseable as * JSON, which guarantees `read()` returns the document byte-for-byte as `write()` received it. * eve's own document format starts with an HTML comment today, but the backend contract is "any * UTF-8 string" and a corrupted round-trip would surface as an opaque * "Memory backend returned an invalid versioned memory document." much later. * * The prefix is deliberately *outside* `agentkit:memory:` — that one belongs to `AgentMemory`'s * search index, and a document written under it would be indexed as a malformed memory doc. */ /** Configuration for {@link redisDocuments}. */ interface RedisDocumentsConfig { /** * Upstash Redis client. * * @default Redis.fromEnv() */ redis?: Redis; /** * Key prefix for the per-scope document hashes. * * Deliberately **not** under `agentkit:memory:` — that prefix is {@link AgentMemory}'s Redis * Search index prefix, and a document written under it would be picked up by that index as a * malformed memory doc. * * @default "agentkit:memoryFile" */ prefix?: string; /** * Optional expiry, refreshed on every successful write. Omit for durable memory; set it for * scopes that should age out (a per-conversation or per-ticket slot, say). Applied inside the * same Lua script as the write, so it can never outlive a failed compare-and-set. * * @default undefined — documents are kept indefinitely */ ttlSeconds?: number; /** * Report the sdk name + version to Upstash as a header on the requests made by your redis client. * Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env var. * * @default true */ enableTelemetry?: boolean; } /** * An Upstash Redis implementation of eve's {@link MemoryDocumentBackend}: one versioned document * per scope key, with a real optimistic-concurrency `write()`. Construct it via {@link redisDocuments}. */ declare class RedisMemoryDocumentBackend implements MemoryDocumentBackend { private readonly redis; private readonly prefix; private readonly ttlSeconds; constructor(config?: RedisDocumentsConfig); /** The Redis key holding one scope's document. eve's scope key is already an opaque digest. */ keyFor(scopeKey: string): string; /** * One `HMGET` of the document hash, normalized to eve's {@link MemoryDocument} or `null`. * * The fields are typed `unknown` on purpose — do not "tighten" them to `string`. `@upstash/redis` * auto-deserializes replies, so a value that parses as JSON comes back as a number/object even * though a string was written. {@link CONTENT_MARKER} makes that impossible for `content`, but * the type has to describe what the client can actually return, and the `typeof` guards below * are what turn it back into a document. */ private load; /** Read the document for a scope key, or `null` when the scope has none yet. */ read: ({ key, signal }: MemoryDocumentReadInput) => Promise; write: ({ content, expectedVersion, key, signal, }: MemoryDocumentWriteInput) => Promise; } /** * An Upstash Redis document backend for eve's `fileMemory()`. Drop-in replacement for the default * (Vercel Blob / in-memory) backend and for `vercelBlob()`: * * ```ts * provider: fileMemory({ backend: redisDocuments() }) * ``` * * This is what makes `fileMemory()` work off Vercel — without a `backend` it errors outside * `eve dev` and Vercel-with-Blob. Recall behavior and the `save_memory`/`remove_memory` tools are * unchanged; only the storage moves. */ declare function redisDocuments(config?: RedisDocumentsConfig): MemoryDocumentBackend; /** Context shared by every recall handler this provider registers. */ type RedisMemoryRecallContext = MemoryTurnStartedContext | MemoryCompactionCompletedContext; /** Context shared by every capture handler this provider registers. */ type RedisMemoryCaptureContext = MemoryTurnCompletedContext | MemoryCompactionRequestedContext; /** * What {@link RedisMemoryConfig.rememberMessages} may be set to. * * - `true` (the default) / `"fromUser"` — the caller's own turn text. * - `"all"` — the caller's text *and* the assistant's reply. * - `"fromModel"` — only the assistant's reply. * - `false` — nothing is captured automatically; the model curates memory through `save_memory`, * exactly like eve's own `fileMemory()`. * * The two modes that capture the assistant's reply — `"all"` and `"fromModel"` — drop the * `forget_memory` tool. See {@link RedisMemoryConfig.rememberMessages} for why. */ type RememberMessages = boolean | "fromUser" | "fromModel" | "all"; /** Configuration for {@link redisMemory}. */ interface RedisMemoryConfig { /** * Upstash Redis client. * * @default Redis.fromEnv() */ redis?: Redis; /** * Write memories automatically at the end of each settled turn, with no tool call from the model. * **Defaults to `true`, which means `"fromUser"`** — the caller's own text. `"all"` adds the * assistant's reply, `"fromModel"` captures only that, and `false` turns capture off entirely so * the model curates memory through `save_memory`, exactly like eve's `fileMemory()`. * * ## `"all"` and `"fromModel"` remove `forget_memory` * * Not a safety rail bolted on — those modes make deletion undeliverable, so the tool would be * lying. Measured over 18 black-box conversations against this provider: after the model was asked * to forget one fact, the fact itself was correctly redacted, but the phrase survived in **three** * other records, and all three were `agentMessage` — the assistant's own replies *about* the * deletion. Confirming an erasure records the erased text. Deleting more would write more. * * So a slot that stores the assistant's replies cannot honour "forget this", and offering a tool * that reports success is worse than offering none: a caller told "I permanently deleted every * stored item that mentioned it" reasonably believes it. `search_memory` and `read_session` still * work, so nothing becomes unreachable — it just stops claiming to be removable. * * ## Why the default is the caller's text only * * Beyond deletion: the assistant's reply is *derived from the recalled block*, so capturing it * re-memorizes the agent's own restatements, and those can outrank the fact they restate. In the * same test run agent replies were 18 of 41 records — half the store, and the entire source of the * deletion leak. * * Automatic recall injects only `save_memory` facts either way, so captured turns never compete * with curated ones for `topK`; they are reached deliberately through `search_memory` and * `read_session`. * * @default true — the same as `"fromUser"` */ rememberMessages?: RememberMessages; /** * Base key prefix for stored memories. Defaults to `agentkit:memorySlot`, which is deliberately * **not** the `agentkit:memory` store {@link defineMemorySaveTool} writes to: this slot's schema * indexes extra fields, and a stricter schema over a keyspace that already holds records without * them would make those records unreachable. It therefore owns one of the database's 10 indexes. * Memories are isolated by the per-user key part, which is eve's scope key. * * @default "agentkit:memorySlot" */ prefix?: string; /** * Redis Search index name. * * @default the identifier-safe form of `prefix` */ indexName?: string; /** * Max memories recalled per turn. * * @default 5 */ topK?: number; /** * Minimum BM25 relevance for a recalled memory. Scores are unbounded, not `[0,1]`. * * @default 0 — `AgentMemory`'s own default */ minScore?: number; /** * Character budget for the **recalled block**, including its heading. Defaults to 4,000 — the same * default as eve's `fileMemory()`. Lowest-ranked memories are dropped to fit (rather than the * text being cut mid-entry, or the recall throwing as `fileMemory()` does: this store is * unbounded and rank-ordered, so dropping the tail is the meaningful behavior). * * @default 4000 */ maxRecallCharacters?: number; /** * Longest single **stored memory**, in characters. Defaults to 2,048 — matching eve's per-entry * cap. Longer texts (pasted logs, a whole file) are skipped, not truncated: a truncated paste is * noise in a BM25 index, and dropping it keeps recall useful. * * @default 2048 */ maxMemoryCharacters?: number; /** * TTL, in seconds, of the per-`operationId` recall replay cache. Defaults to 3,600; `0` disables * it. * * eve does the replay bookkeeping itself — it records a digest of the accepted recall result and * rejects a replay whose result differs ("Memory recall operation … replayed with a different * result"). Its docs are explicit that a provider therefore does **not** need to persist recall * results by `operationId` *"unless its store can change before a replay"* * (`docs/memory/custom-provider.md`, clarified in vercel/eve#2951). * * This provider is that exception, which is why the cache is on by default. Recall is a live * ranked query plus two live counts over a store the same turn actively writes to: `save_memory` * adds a `source: "agent"` record — exactly what recall ranks — and the model can call it * mid-turn; `forget_memory` flips `deleted`; capture appends the turn's messages; and a second * session sharing the scope key can do any of it concurrently. Replaying `turn.started` after any * of those would legitimately produce a different block, and eve would reject the turn. Caching * the rendered block under the `operationId` makes a replay return what it returned the first * time. It is keyed per operation, not per session, so each new turn still runs a fresh query. * * @default 3600 */ replayCacheTtlSeconds?: number; /** * Key prefix for the replay cache. * * @default "agentkit:memoryRecall" */ replayCachePrefix?: string; /** * Block on `waitIndexing()` after a capture writes, so the memory is recallable on the **next** * turn. Defaults to `true`. * * This is load-bearing, not a nicety. Upstash Redis Search indexes asynchronously, and measured * against a live database the lag after a plain `json.set` is **tens of seconds** — an end-to-end * eve run captured a fact at `turn.completed` and still recalled nothing eight turns and ten * seconds later, then found it minutes afterwards. Since eve runs capture *after* the response * has been delivered, waiting there costs the user nothing and is what makes "tell the agent * something, ask about it next turn" actually work. Set `false` only if your writes are hot * enough that you would rather trade freshness for fewer round-trips. * * @default true */ waitForIndexing?: boolean; /** * Report the sdk name + version to Upstash as a header on the requests made by your redis client. * Can also be disabled with the `UPSTASH_DISABLE_TELEMETRY` env var. Defaults to `true`. * * @default true */ enableTelemetry?: boolean; } /** * Where a stored record came from. **Indexed**, which is what lets recall ask for curated facts * alone instead of ranking them against raw conversation. * * - `"agent"` — the model chose to remember it, through `save_memory`. * - `"userMessage"` — captured from the caller's own turn text. * - `"agentMessage"` — captured from the assistant's reply (`rememberMessages: "fromModel"`/`"all"`). */ type MemorySource = "agent" | "userMessage" | "agentMessage"; /** * A full eve {@link MemoryProvider} backed by AgentKit's {@link AgentMemory} on Upstash Redis: * ranked (BM25 `$smart`) recall at `turn.started` and `compaction.completed`, plus * `save_memory`/`search_memory`/`forget_memory` tools bound to the slot's locked scope. Automatic capture and * conversation capture are both opt-in. * * ```ts * // agent/memory/recall.ts * import { defineMemory } from "eve/memory"; * import { byPrincipal } from "eve/memory/scope"; * import { redisMemory } from "@upstash/agentkit-eve/memory"; * * export default defineMemory({ * description: "Recall what the caller has told this agent before.", * provider: redisMemory({ topK: 5 }), * scope: byPrincipal, * }); * ``` * * Unlike eve's `fileMemory()`, the store is unbounded and recall is ranked rather than wholesale: * what bounds model context is `maxRecallCharacters` on the *recalled block*, not the store. */ declare function redisMemory(config?: RedisMemoryConfig): MemoryProvider; export { type MemorySource, type RedisDocumentsConfig, type RedisMemoryCaptureContext, type RedisMemoryConfig, RedisMemoryDocumentBackend, type RedisMemoryRecallContext, type RememberMessages, redisDocuments, redisMemory };