Skip to content

feat: @upstash/agentkit-tanstack-ai (TanStack AI backends on Upstash Redis) - #48

Open
CahidArda wants to merge 13 commits into
mainfrom
feat/tanstack-ai
Open

CahidArda wants to merge 13 commits into
mainfrom
feat/tanstack-ai

Conversation

@CahidArda

@CahidArda CahidArda commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Part of DX-3081.

Why

TanStack AI (0.61) defines the extension points for an agent's production state — persistence stores, resumable streams, locks, memory — and ships only in-memory implementations (memoryPersistence, memoryStream, InMemoryLockStore, inMemory() memory, …). They work in one process and lose everything between serverless instances. No Redis or Postgres backends existed on npm. This PR implements them on Upstash Redis (plus Upstash Blob for file bytes).

The Box sandbox provider already exists upstream as @tanstack/ai-sandbox-upstash-box, so it is not duplicated here.

Exports of @upstash/agentkit-tanstack-ai

Three entry points, so the root never needs TanStack's optional packages: @upstash/agentkit-tanstack-ai (stream, locks, middlewares, search tools, RedisLock/EventLog), @upstash/agentkit-tanstack-ai/persistence (upstashPersistence, upstashBlobStore; needs @tanstack/ai-persistence) and @upstash/agentkit-tanstack-ai/memory (upstashMemory, memoryScopeKey; needs @tanstack/ai-memory).

Export What it does Built on (AgentKit core / Upstash) Plugs into (TanStack AI) Env vars read
upstashPersistence() Durable chat and generation state: thread transcripts, run records (reconnect via findActiveRun, subagent cards, reclaim reaping), human-in-the-loop interrupts, metadata, one-shot generation jobs and their artifacts, plus file bytes when given a Blob bucket RedisJSON documents + sorted-set indexes via @upstash/redis (JSON.SET NX / JSON.MERGE, single-EVAL Lua writes with allow-key-locking); no core primitive. Blobs via upstashBlobStore withPersistence() / withGenerationPersistence() from @tanstack/ai-persistence — all 7 stores: messages, runs, interrupts, metadata, generationRuns, artifacts, blobs UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN (+ UPSTASH_BLOB_TOKEN if the bucket is Bucket.fromEnv())
upstashBlobStore() Stores generated bytes (images, audio, video) with ranged reads; used by upstashPersistence({ bucket }), also usable alone Upstash Blob for bytes (@upstash/blob Bucket, optional peer) + RedisJSON records and a lexical key index BlobStore from @tanstack/ai-persistence None (redis and bucket are passed in)
upstashStream() Resumable delivery: every chunk is logged before it is sent, so a reload or second device replays and keeps tailing the live run on any instance EventLog (this package, stream/) → Redis Streams StreamDurability from @tanstack/ai (durability.adapter of toServerSentEventsResponse, replayRunStream) UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN
upstashLocks() Distributed locks for TanStack AI middleware (withSandbox uses them so concurrent requests never create duplicate sandboxes; withLocks does not lock whole turns). Lease renewed while the section runs, signal aborts on loss RedisLock (this package, locks/) → SET NX PX + Lua LockStore for withLocks() from @tanstack/ai/locks (read by withSandbox, run claims) UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN
upstashMemory() Long-term memory: recalls the most relevant memories into the system prompt each turn, captures user messages, offers a save_memory tool Core AgentMemory → Upstash Redis Search (BM25 $smart) MemoryAdapter for memoryMiddleware() from @tanstack/ai-memory UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN
memoryScopeKey() Maps a TanStack memory scope (tenant / namespace / user or thread) to a collision-proof AgentMemory userId — MemoryScope (= Scope from @tanstack/ai) None
toolCache() Serves repeated calls of allowlisted tools from Redis and skips the tool; stores successful results Core ToolCache ChatMiddleware (onBeforeToolCall → skip, onAfterToolCall) UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN
rateLimit() Spends one unit per top-level run and fails the run before the model is called when over the limit Core createRateLimit → @upstash/ratelimit ChatMiddleware (onStart) UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN
RateLimitExceededError The error rateLimit() fails a run with (identifier, limit, remaining, reset) — Surfaces as the run's RUN_ERROR None
createSearchTools() search / aggregate / count tools over your own documents (RAG), descriptions generated from the schema, index created on first use Core createSearchToolDefs → Redis Search Server tools built with toolDefinition().server() for chat({ tools }) UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN
createRateLimit, Ratelimit Re-exports, for a route that wants to answer 429 before chat() Core createRateLimit, @upstash/ratelimit — UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN (createRateLimit)

Redis variables are read only when no redis client is passed. Every export honours UPSTASH_DISABLE_TELEMETRY.

Redis primitives (also exported from this package)

Export What it does Used by
RedisLock (+ LockLease, LockAcquireTimeoutError, LockLostError) Lease lock with a never-expiring fencing counter; ownership-checked release/extend in Lua; withLock renews in the background and aborts the section's signal on loss upstashLocks()
EventLog (+ LogEntry) Append-only resumable log on Redis Streams: atomic batch appends, resume strictly after a position, close + final drain, snapshot, TTL for orphaned logs upstashStream()

Source layout (packages/tanstack-ai/src): persistence/, stream/, locks/, memory/, middleware/, search/, and testing/ (test-only helpers).

Verification (live Upstash Redis)

  • TanStack's own backend test kits, both shipped by TanStack AI:
    • runPersistenceConformance (@tanstack/ai-persistence/testkit) against upstashPersistence with all seven stores, nothing skipped: 26/26. Blob bytes go to a stand-in bucket whose signed URLs are served over real HTTP with Range support. A second copy runs against a real Upstash Blob bucket through the @upstash/blob SDK when UPSTASH_BLOB_TOKEN is set: 26/26 on 2026-09-28, including the byte-range case over signedReadUrl.
    • runMemoryAdapterContract (@tanstack/ai-memory/testkit) against upstashMemory: 8/8.
  • Middleware, memory and search tests drive a real chat() agent loop through a scripted TextAdapter (tool call → result → final answer), no provider key needed.
  • Resumable stream tested across two clients: resume after an offset, a mid-run joiner tailing until close, an SSE response from toServerSentEventsResponse replayed by replayRunStream.
  • Full repo: lint ✅ typecheck ✅ build ✅ sync-version --check ✅ — 287 passed / 3 skipped / 0 failed (3 skipped = the existing Box-key tests; the live-Blob suite runs).

Demo + end-to-end suite

Recorded run of examples/tanstack-ai-demo: two production instances (:4310, :4311) on one Upstash Redis, scripted model at 150 ms/word. A reload mid-answer resumes; the same thread opened on the other instance mid-answer finishes there; remember: on B is recalled in a new thread on A.

tanstack-ai-demo.mp4

examples/tanstack-ai-demo is TanStack AI's own persistent-chat pattern (their ts-react-chat example) on these backends, with a scripted model behind AGENTKIT_MOCK_MODEL=1. pnpm e2e starts two production servers against one Upstash Redis and checks, 6/6:

  • a client that drops mid-answer on A resumes the same run on B, nothing missed or repeated
  • a reload on B mid-answer sees the run still in flight on A, then the finished transcript
  • a fact saved in one thread on A is recalled in a new thread on B, and not for another user
  • the same run POSTed to both instances at once is produced exactly once (RedisLock)
  • a run that outlives its producer lease is still produced once when re-POSTed elsewhere (the lease is renewed for the run; this test fails without renewal)
  • a malformed request is a 400

The demo also caught that package.json#exports had dropped ./persistence and ./memory; fixed, with src/package-exports.test.ts guarding it. CI runs the suite after the example builds.

Not in this PR (follow-ups)

Code Mode isolate driver on Upstash Box, Redis sandbox instance/checkpoint stores, QStash background runs writing to upstashStream, and a QStash schedule for reapDetachedRuns.

CLAUDE.md's old "don't reintroduce tanstack-ai" note is replaced with a section explaining why this package differs from the adapter removed in June.

Related: upstash/docs#874, upstash/skills#53, upstash/upstash-web#661.

Upstash Box added 2 commits September 27, 2026 08:31
RedisLock: SET NX PX lease + never-expiring fencing counter, ownership-checked
release/extend in Lua, background renewal that aborts the section's signal on loss.
EventLog: append-only resumable log on Redis Streams (atomic MULTI appends,
exclusive-range resume, close + final drain, snapshot, TTL for orphaned logs).
New @upstash/agentkit-tanstack-ai implementing TanStack AI's own backend
contracts, which upstream only ships in-memory:

- upstashPersistence(): messages/runs/interrupts/metadata for withPersistence();
  passes TanStack's runPersistenceConformance suite (26/26) on live Redis
- upstashStream(): resumable StreamDurability on Redis Streams (core EventLog)
- upstashLocks(): distributed LockStore for withLocks() (core RedisLock)
- upstashMemory(): MemoryAdapter for memoryMiddleware(), ranked in Redis Search
- toolCache() / rateLimit() chat middlewares, createSearchTools() for RAG

Tests drive a real chat() agent loop via a scripted TextAdapter. Docs,
CLAUDE.md section (replaces the stale "don't reintroduce tanstack-ai" note),
changesets, version-sync target.
@linear-code

linear-code Bot commented Sep 27, 2026

Copy link
Copy Markdown

DX-3081

…dexing on save

upstashMemory now provisions its index before the first write and waits for
indexing after each save (waitForIndexing, default true), so a saved turn is
recallable on the next turn. Adds runMemoryAdapterContract from
@tanstack/ai-memory/testkit alongside the persistence conformance suite.
Upstash Box added 2 commits September 27, 2026 15:49
… blobs stores

- generationRuns: hash per job + per-thread zset (findLatestForThread)
- artifacts: hash per record, run/thread zsets ordered createdAt then id bytes;
  re-save moves indexes atomically in one Lua script
- blobs (upstashPersistence({ bucket })): bytes in Upstash Blob, records and a
  lexical key index in Redis; ranges via signedReadUrl + HTTP Range
- @upstash/blob is an optional peer; the store takes a structural bucket

TanStack's runPersistenceConformance now runs every store with nothing skipped.
A live-Blob copy of the suite runs when UPSTASH_BLOB_TOKEN is set.
Same flag as @upstash/ratelimit #154: Upstash locks only the declared keys
instead of the whole database. Applies to RedisLock (sdk), redisDocuments CAS
(eve) and all tanstack-ai persistence scripts.

Under the flag an undeclared key is an error (verified live), so:
- CREATE_RUN declares the parent index only when there is a parent (no
  placeholder key)
- SAVE_ARTIFACT no longer reads its old index keys inside Lua; the caller
  reads and declares them and the script compare-and-swaps, retrying on a race

Adds live tests for both variable-key paths and concurrent artifact saves.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Lock-key collisions, terminal-log races, concurrent persistence corruption, and adapter scoping issues must be resolved.

Review effort: Balanced
Findings: 5 High severity · 3 Medium severity

Open (8)
What changed in this PR

Adds production-grade TanStack AI backends backed by Upstash Redis, plus reusable coordination primitives in the core SDK.

Changes:

  • Adds RedisLock and EventLog to the core SDK.
  • Introduces persistence, streams, locks, memory, middleware, search, and Blob support for TanStack AI.
  • Adds integration/conformance tests, documentation, telemetry, and release metadata.
File Description
.changeset/​lua-key-locking.md Records Eve Lua locking change.
.changeset/​sdk-lock-event-log.md Records new SDK primitives.
.changeset/​tanstack-ai-initial.md Records initial package release.
CLAUDE.md Documents architecture and conventions.
README.md Lists the new package and primitives.
pnpm-lock.yaml Locks TanStack AI dependencies.
scripts/​sync-version.mjs Adds package version synchronization.
packages/​eve/​src/​memory/​documents.ts Enables scoped Lua key locking.
packages/​sdk/​src/​event-log.ts Implements Redis Streams event logs.
packages/​sdk/​src/​event-log.test.ts Tests event-log behavior.
packages/​sdk/​src/​index.ts Exports coordination primitives.
packages/​sdk/​src/​lock.ts Implements Redis lease locks.
packages/​sdk/​src/​lock.test.ts Tests locking and renewal.
packages/​tanstack-ai/​LICENSE Adds package license.
packages/​tanstack-ai/​README.md Documents package usage.
packages/​tanstack-ai/​package.json Defines package metadata and peers.
packages/​tanstack-ai/​tsconfig.json Configures TypeScript compilation.
packages/​tanstack-ai/​tsup.config.ts Configures ESM packaging.
packages/​tanstack-ai/​src/​blob-store.ts Implements Redis/Blob storage.
packages/​tanstack-ai/​src/​codec.ts Encodes Redis values safely.
packages/​tanstack-ai/​src/​generation-stores.ts Implements generation and artifact stores.
packages/​tanstack-ai/​src/​index.ts Exports the public API.
packages/​tanstack-ai/​src/​integration.test.ts Tests persistence and stream integration.
packages/​tanstack-ai/​src/​locks.ts Adapts RedisLock to TanStack AI.
packages/​tanstack-ai/​src/​locks.test.ts Tests distributed lock integration.
packages/​tanstack-ai/​src/​memory.ts Implements Redis Search memory.
packages/​tanstack-ai/​src/​memory.test.ts Tests memory contracts and isolation.
packages/​tanstack-ai/​src/​middleware.ts Adds cache and rate-limit middleware.
packages/​tanstack-ai/​src/​middleware.test.ts Tests middleware through chat loops.
packages/​tanstack-ai/​src/​persistence.ts Implements chat persistence stores.
packages/​tanstack-ai/​src/​persistence.test.ts Runs persistence conformance tests.
packages/​tanstack-ai/​src/​search-tools.ts Adapts schema-driven search tools.
packages/​tanstack-ai/​src/​search-tools.test.ts Tests search tools in chat loops.
packages/​tanstack-ai/​src/​stream.ts Implements resumable Redis streams.
packages/​tanstack-ai/​src/​stream.test.ts Tests replay, joining, and offsets.
packages/​tanstack-ai/​src/​telemetry.ts Adds package telemetry tagging.
packages/​tanstack-ai/​src/​test-adapter.ts Provides a scripted test adapter.
packages/​tanstack-ai/​src/​test-bucket.ts Provides an HTTP-backed test bucket.
packages/​tanstack-ai/​src/​test-support.ts Adds shared live-test utilities.
packages/​tanstack-ai/​src/​version.ts Defines generated package version.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/tanstack-ai/src/stream/event-log.ts Outdated
Comment thread packages/tanstack-ai/src/locks/redis-lock.ts
Comment thread packages/tanstack-ai/package.json
Comment thread packages/tanstack-ai/src/persistence/blob-store.ts Outdated
Comment thread packages/tanstack-ai/src/persistence/generation-stores.ts Outdated
Comment thread packages/tanstack-ai/src/middleware.ts Outdated
Comment thread packages/tanstack-ai/src/middleware.ts Outdated
Comment thread packages/tanstack-ai/src/stream/stream.ts
Upstash Box added 3 commits September 28, 2026 14:02
…tore plumbing

- Records (runs, interrupts, generation runs, artifacts, blob records,
  metadata, transcripts) are RedisJSON documents: values keep their JSON types,
  so codec.ts and per-field encoding are gone. Creates are JSON.SET NX,
  patches JSON.MERGE behind an EXISTS guard (a bare merge creates the key).
- records.ts holds the shared CREATE/PATCH scripts, merge-patch conversion
  (replace semantics for nested objects), loadDocs, assertId, checkRun;
  persistence.ts and generation-stores.ts no longer duplicate them.
- Artifact index bookkeeping moved to a side hash so the document is exactly
  the record.
- Adapter configs reuse the core types (RedisLockConfig, EventLogConfig,
  ToolCacheConfig, AgentMemoryConfig, SearchToolDefsConfig) and spread them
  through instead of copying fields one by one.
- Telemetry: rateLimit tags its default client, upstashBlobStore tags its own.

Behaviour unchanged: TanStack persistence conformance 26/26 (stand-in and live
Upstash Blob), memory contract 8/8, full repo 254 passed.
`inputSchema as never` typed the tool args as never, which made the tool
unassignable to Tool and forced `as unknown as Tool`. The zod schema is
already a valid SchemaInput, so both casts go; the memory save tool's cast was
never needed.
…es by feature

RedisLock and EventLog move from @upstash/agentkit-sdk to
@upstash/agentkit-tanstack-ai (their only consumer) and are exported from
there; the core SDK no longer exports them and its changeset is dropped.

tanstack-ai/src is grouped into persistence/, stream/ (upstashStream +
EventLog), locks/ (upstashLocks + RedisLock), memory/, middleware/, search/
and testing/. No behaviour change: full repo 254 passed / 3 skipped.
@CahidArda CahidArda changed the title feat: @upstash/agentkit-tanstack-ai + core RedisLock/EventLog feat: @upstash/agentkit-tanstack-ai (TanStack AI backends on Upstash Redis) Sep 28, 2026
- Subpath entry points: ./persistence and ./memory. The root entry no longer
  references @tanstack/ai-persistence or @tanstack/ai-memory types, verified
  with a consumer that has only @tanstack/ai and skipLibCheck off.
- RedisLock: leases live under lease:<key>, so "fence:x" can't collide with the
  fencing counter for "x" (regression test added).
- EventLog: append is one script that refuses a closed log
  (EventLogClosedError) and keeps the stream's TTL aligned with the closed flag.
- upstashStream: object form stays bound to its explicit runId; a mismatched
  offset is rejected by read(), matching memoryStream.
- Blob store: every put uploads to an immutable versioned path; the record and
  version pointer swap atomically and the replaced bytes are deleted after
  commit, so concurrent puts/deletes can't mismatch record and bytes.
- Artifacts: delete is a compare-and-swap script like save, so a concurrent
  move can't leave a dangling index entry.
- toolCache: pending calls keyed by requestId + toolCallId.
- README: subpath imports; correct description of withLocks.

Full repo 259 passed / 3 skipped; live-Blob conformance 26/26.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread packages/tanstack-ai/package.json
Comment on lines +243 to +247
const [record, path] = (await tx.exec()) as [BlobRecord | null, string | null];
if (!record || !path) return null;
let read;
try {
read = await readBytes(path, record, options?.range);
Comment on lines +122 to +125
if (!run || !thread) {
// Not indexed: at most a bare document is left (e.g. from a crashed save) — drop it.
await redis.del(k.artifact(id), k.where(id));
return;
},

async listFacts(scope): Promise<MemoryFact[]> {
const records = await memory.list({ userId: memoryScopeKey(scope, scopeBy), limit: 100 });
Comment thread CLAUDE.md Outdated
- **Redis primitives** (`src/locks/redis-lock.ts`, `src/stream/event-log.ts`; moved here from core 2026-09-28, exported from this package): `RedisLock` (+ `LockLease`, `LockAcquireTimeoutError`,
`LockLostError`) — `SET NX PX` lease + a never-expiring `INCR` fencing counter, release/extend are
ownership-checked Lua; `withLock` renews every `leaseMs/3` and aborts the section's signal on loss.
`EventLog` (+ `LogEntry`) — Redis Streams: `append` is one `MULTI` of `XADD`s (atomic, ordered ids),
Comment thread packages/tanstack-ai/src/memory/memory.ts Outdated
Comment thread packages/tanstack-ai/src/persistence/persistence.ts Outdated
Comment on lines +12 to +13
export function addTelemetry(redis: unknown, enableTelemetry?: boolean): void {
tagClient(redis, { sdk: TANSTACK_AI_TELEMETRY, enabled: enableTelemetry });
Upstash Box and others added 3 commits September 28, 2026 14:57
…ce check

Bumps @tanstack/ai to 0.63.0, @tanstack/ai-persistence to 0.7.1 and
@tanstack/ai-memory to 0.2.8 (dev deps and peer floors), matching the rest of
the TanStack AI 0.63 line (ai-react, ai-openai) so an app gets one copy.

The 0.7.1 conformance kit adds two opt-in checks (messages.metadata,
runs.listByThread.state); both are enabled and pass, live Blob included.
Full repo: 263 passed / 3 skipped.
…issing subpath exports

examples/tanstack-ai-demo is TanStack AI's own persistent-chat pattern (from
their ts-react-chat example) on Upstash: detached runs, upstashPersistence,
upstashStream, reconstructChat, upstashMemory, and RedisLock in place of the
process-local "one producer per run" set. AGENTKIT_MOCK_MODEL=1 runs a scripted
model, so it needs no provider key.

`pnpm e2e` starts two production servers against one Upstash Redis and checks:
resume after a mid-answer drop on the other instance (nothing missed or
repeated), mid-run reconstruct on the other instance then the final
transcript, memory across threads and instances (isolated per user), exactly
one production of a run POSTed to both instances, and a 400 on a bad body.
The CI step (after the example builds, skipped without Redis secrets) is added separately: the box token cannot push workflow changes.

Building the demo caught a real bug: package.json#exports had lost
./persistence and ./memory (dist was built, nothing exported). Restored, and
src/package-exports.test.ts now pins exports to the tsup entries.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread examples/tanstack-ai-demo/app/api/chat/route.ts Outdated
Comment thread examples/tanstack-ai-demo/app/api/chat/route.ts Outdated
Comment thread examples/tanstack-ai-demo/.env.example Outdated
Comment thread examples/tanstack-ai-demo/lib/model.ts Outdated
Package:
- Artifact delete: the not-indexed case goes through the compare-and-swap
  script too, so a first save racing it can't leave dangling index entries.
- Blob get: if a concurrent overwrite deletes the version being read, re-read
  the record/pointer snapshot instead of reporting a live key missing
  (deterministic regression test).
- upstashMemory.listFacts returns every fact (sized from count) instead of
  silently truncating at 100 (test with 120 facts).
- Wire-level telemetry tests for every factory: tag present, one tag per
  shared client, enableTelemetry: false.
- JSDoc examples import from ./persistence and ./memory; CLAUDE.md EventLog note.

Demo:
- The producer holds a renewed lease for the whole run (withLock with an
  immediate timeout = "already produced elsewhere"); on lease loss it aborts
  and leaves the log to the new owner.
- The detached run is handed to Next.js after() (+ maxDuration) so it survives
  the response on serverless hosts.
- New E2E: a run that outlives a 1s lease is still produced once when
  re-POSTed to the other instance. Verified to fail without renewal (two
  producers interleave into one log; asserted via TEXT_MESSAGE_START count).
- Default model gpt-5.4-mini, like the other demos.

Full CI sequence green: 287 passed / 3 skipped, example builds, E2E 6/6.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants