From 3485b601bae68903f25548a80077ceeaef2092c6 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Wed, 12 Aug 2026 19:07:18 +0300 Subject: [PATCH 1/4] feat!: upgrade to eve 0.32 (ai 7.0.58) - eve ^0.32.0 + ai exact-pin 7.0.58 across all packages and demos; @ai-sdk/* ranges re-resolved to the latest minors so a single ai copy remains. - sandbox backend: implement eve >=0.32's required SandboxBackendHandle.stop() (authored-runtime pause; rejects on provider errors, unlike best-effort shutdown()). - tool factories return new ResolvedToolDefinition (eve >=0.31 widened execute's return type with AsyncIterable streaming snapshots; this re-narrows it to Promise for direct callers). - defineCachedTool drains async-generator executors and caches the final snapshot (cached tools don't stream). - eve-demo: agent.send is positional in eve >=0.31 (send(value), not send({message})). - eve-extension dist rebuilt with eve 0.32: manifest now requires 0.32's contribution formats, so consumers need eve >=0.32. All 69 tests pass against real Redis/Box; all example apps build. --- .changeset/eve-032-upgrade.md | 26 ++ CLAUDE.md | 64 +++-- examples/ai-sdk-demo/package.json | 6 +- .../eve-demo/app/_components/agent-chat.tsx | 2 +- examples/eve-demo/package.json | 22 +- examples/eve-extension-demo/package.json | 6 +- packages/ai-sdk/package.json | 8 +- packages/eve-extension/README.md | 2 +- packages/eve-extension/package.json | 2 +- packages/eve/package.json | 4 +- packages/eve/src/index.ts | 2 +- packages/eve/src/memory.ts | 11 +- packages/eve/src/sandbox.ts | 16 +- packages/eve/src/search-tools.ts | 12 +- packages/eve/src/tools.ts | 46 +++- pnpm-lock.yaml | 260 +++++++++++++----- 16 files changed, 348 insertions(+), 141 deletions(-) create mode 100644 .changeset/eve-032-upgrade.md diff --git a/.changeset/eve-032-upgrade.md b/.changeset/eve-032-upgrade.md new file mode 100644 index 0000000..afaddb6 --- /dev/null +++ b/.changeset/eve-032-upgrade.md @@ -0,0 +1,26 @@ +--- +"@upstash/agentkit-eve": minor +"@upstash/agentkit-eve-extension": minor +--- + +Upgrade to eve 0.32 (repo now builds and tests against eve 0.32.0 / AI SDK 7.0.58). + +`@upstash/agentkit-eve`: + +- The Upstash Box sandbox backend implements eve ≥0.32's `SandboxBackendHandle.stop()` (authored + `ctx.getSandbox().stop()`): pauses the box, keeps the session reattachable, and rejects on provider + errors per the contract (`shutdown()` stays best-effort). +- New exported type `ResolvedToolDefinition`: eve ≥0.31 widened + `ToolDefinition.execute`'s return type to include `AsyncIterable` (streaming output + snapshots); all agentkit tool factories (`defineCachedTool`, `defineMemoryRecallTool`, + `defineMemorySaveTool`, `defineSearchTools`) now return this narrowed type, so calling `execute` + directly still resolves to a plain `Promise`. +- `defineCachedTool` handles streaming executors: an async-generator `execute` is drained and only its + final snapshot is cached and returned (a cache hit cannot replay intermediate snapshots). + +`@upstash/agentkit-eve-extension`: + +- The prebuilt `dist/extension` is now built with eve 0.32, so its compatibility manifest requires + eve 0.32's contribution formats — **consumers need eve ≥0.32** to mount this version of the + extension. (The eve ≥0.25.3 fix for extensions installed as physical `node_modules` directories + means the old pnpm-only caveat is gone.) diff --git a/CLAUDE.md b/CLAUDE.md index 8c63d9a..fb38fbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,8 +76,14 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). conversations. That's lookup-on-demand, not session resume: the same no-round-trip caveat holds.) - `./sandbox` → `upstash()` Upstash Box backend. **⚠ INCOMPLETE — see Known issues.** - Eve is file-centric, but the tool factories now **call `defineTool` internally** and return the - branded `ToolDefinition` — users export them directly (no outer `defineTool(...)` wrap). Because of - this, **`eve` is a required (non-optional) peer dep** of `packages/eve`. + branded definition — users export them directly (no outer `defineTool(...)` wrap). Because of + this, **`eve` is a required (non-optional) peer dep** of `packages/eve`. The factories return + **`ResolvedToolDefinition`** (exported from the root): eve ≥0.31 widened + `ToolDefinition.execute`'s return to `Promise | TOutput | AsyncIterable` (streaming + output snapshots), so this `Omit`-based type re-narrows `execute` to `Promise` for direct + callers (a plain intersection does NOT override — TS picks the union signature first). +- **`defineCachedTool` does not stream:** if the user's `execute` is an async generator, it's drained + and only the **final snapshot** is cached and returned (a cache hit can't replay a stream anyway). - Rate limiting in eve = a route-auth gate: `createRateLimitAuth(config)` goes first in `eveChannel({ auth: [...] })`; it `.limit()`s, throws `ForbiddenError` (403) over the limit, else returns `null` to fall through to the real authenticators (`localDev()`/`vercelOidc()`/…). @@ -96,9 +102,12 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). it, and pnpm's strict layout rejects the phantom dep (npm hoists and hides it). The old "also install `@upstash/agentkit-sdk`" workaround is **not** needed on the 0.25 format — the compiled dist resolves sdk from the extension's own package. - **Known eve bug:** `eve dev` fails to load an extension installed as a **real directory** (npm/yarn - hoisted layouts) — the dev snapshot's module-map references `../../node_modules/...` relatively and - misses; pnpm's symlink installs work, and production `eve build` bundles fine either way. + (The old eve bug where `eve dev` failed to load an extension installed as a **real directory** — + npm/yarn hoisted layouts — was fixed upstream in eve 0.25.3; no workaround needed on ≥0.25.3.) + **Consumer eve version:** `eve extension build` stamps the manifest's `requires` with the building + eve's *current* contribution-format versions, and a consumer rejects any version not in its own + supported list — so a dist built with eve 0.32 (tool 11 / dynamicTool 12 / hook 9) needs consumers + on **eve ≥0.32**. The wildcard peer stays `"*"`; the manifest is the real compatibility tie. - `extension/extension.ts` = `defineExtension({ config: zod })`; the default export is the mount factory. Config knobs: `userId` (string or `(ctx: SessionContext) => string` — eve's public base of tool+hook ctx, imported from `eve/tools`), `redis` (defaults `Redis.fromEnv()`), `memory{topK,minScore}`, @@ -175,10 +184,13 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). `agentkit:memory::`, `agentkit:chat::` (default prefixes shown). ## AI SDK version strategy — IMPORTANT -- **AI SDK v7 stable everywhere.** Every package + demo pins `ai` to exactly **`7.0.30`**. `eve` (0.24+) - declares `ai` as a **peer** (`^7.0.26`), so the apps/packages provide the single copy. Providers: - `@ai-sdk/openai` `^4.0.15`, `@ai-sdk/provider` `^4.0.3`, `@ai-sdk/react` `^4.0.33` (all stable). - (History: the repo was on `7.0.0-beta.178`, the exact version `eve@0.13.1` depended on.) +- **AI SDK v7 stable everywhere.** Every package + demo pins `ai` to exactly **`7.0.58`**. `eve` (0.32) + declares `ai` as a **peer** (`^7.0.58`), so the apps/packages provide the single copy. Providers: + `@ai-sdk/openai` `^4.0.37`, `@ai-sdk/provider` `^4.0.7`, `@ai-sdk/react` `^4.0.62` (all stable ranges; + bump them with `pnpm -r update "@ai-sdk/*"` when eve moves — a stale `@ai-sdk/react` range can pin a + second, older `ai` copy via its peer resolution, which is exactly the two-copy breakage to avoid). + (History: the repo was on `7.0.0-beta.178` for `eve@0.13.1`, then `7.0.30` for `eve@0.25.2` — the + exact pin moves in lockstep with eve's `ai` peer range.) - **Why exact-pin and not a pnpm `override`:** because everyone lands on the same exact `ai`, pnpm installs a single copy. Two copies of `ai` cause type/identity breakage. An override was tried and removed as unnecessary — keep it that way unless a dep forces a different `ai@7`. @@ -222,10 +234,20 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). `$count`, `$histogram`, `$percentiles`, `$cardinality`. ## Eve framework facts -- The repo is on **`eve@0.25.2`** (peer `>=0.24.0` in `packages/eve` — its API is unchanged across - 0.24→0.25; wildcard peer in the extension). Subpath exports: `eve/tools`, `eve/hooks`, - `eve/extension`, `eve/context`, `eve/instructions`, `eve/sandbox`, `eve/sandbox/vercel`, - `eve/channels/*`, `eve/next`, … +- The repo is on **`eve@0.32.0`** (peer `>=0.24.0` in `packages/eve`; wildcard peer in the extension, + but the built dist needs consumers on ≥0.32 — see the eve-extension section). Subpath exports: + `eve/tools`, `eve/hooks`, `eve/extension`, `eve/context`, `eve/instructions`, `eve/sandbox`, + `eve/sandbox/vercel`, `eve/channels/*`, `eve/next`, `eve/react`, … +- **Breaking changes absorbed on the 0.25 → 0.32 jump:** (a) 0.31 replaced continuation-token session + APIs with fixed ID-addressed handles — frontend/client `send` is now **positional** + (`agent.send(message, options?)`, not `send({ message })`; eve-demo's `agent-chat.tsx` was updated); + (b) `SandboxBackendHandle` gained a required **`stop()`** (authored-runtime stop, errors must reject) + alongside `shutdown()`; (c) tool executors may return **`AsyncIterable`** (streaming output + snapshots, 0.31) — `ToolDefinition.execute`'s return type is now a union, see `ResolvedToolDefinition`; + (d) 0.30 changed `localDev()` to grant a deployment-based synthetic principal (runtime `principalId` + values differ in local dev; our sanitizing `resolveUserId` is unaffected); (e) eve 0.32's `ai` peer is + `^7.0.58` (drove the repo-wide exact-pin bump). Durable sessions now **complete after 30 days** by + default (0.28) — strengthens Redis `ChatHistory` as the long-term transcript store. - **Extension packaging changed 0.24 → 0.25**: 0.24 shipped source the consumer recompiles; 0.25 ships prebuilt `dist/extension` + `_manifest.json` (see the eve-extension section). 0.25 rejects 0.24-format packages at discovery. @@ -247,9 +269,10 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). - The real `SandboxBackend` is **two-phase**: `{ name, create(input) → SandboxBackendHandle, prewarm(input) → { reused } }`. `SandboxSession` = the AI SDK `Experimental_SandboxSession` (`run`, `spawn`, `readFile`→stream, `readBinaryFile`, `readTextFile`, `writeFile`/`writeBinaryFile`/`writeTextFile`) plus - `id`, `resolvePath`, `setNetworkPolicy`, `removePath`. In eve ≥0.24 the handle's lifecycle method is - **`shutdown()`** (fires only on server shutdown; must leave the session reattachable) — the old - per-open `dispose()` is gone. + `id`, `resolvePath`, `setNetworkPolicy`, `removePath`. The handle's lifecycle methods are + **`stop()`** (eve ≥0.32: authored code ends sandbox work early via `ctx.getSandbox().stop()`; must + keep the session reattachable and **reject** on provider errors) and **`shutdown()`** (server + shutdown; best-effort, failures collected/logged by eve) — the old per-open `dispose()` is gone. ## @upstash/box (sandbox backend) - Optional peer dep of the eve package. `Box.create({ apiKey | UPSTASH_BOX_API_KEY, runtime, size, … })`; @@ -336,10 +359,11 @@ pnpm -r --filter "./examples/*" build # build both demo apps lookup. `prewarm` builds **no** box when there's nothing to bake (no seed files/bootstrap). **Session reuse:** `create` reattaches to the box from `input.existingMetadata.boxId` (`Box.get`) — Eve re-opens a session many times and hands our captured `boxId` back, so without this every open spun a fresh box (the - "3 boxes per turn" bug). `shutdown` (eve ≥0.24's replacement for the old per-open `dispose`) fires only - when the server stops: it `box.pause()`s (reattachable; failure tolerated — keep-alive boxes can't - pause), and `keepAlive` defaults to **false** (pause-based idle; `true` can't be paused and runs until - deleted). **Path bridge:** Eve roots its tools at `/workspace` but Box sessions live in `/workspace/home`, + "3 boxes per turn" bug). Lifecycle: `stop()` (eve ≥0.32, authored `ctx.getSandbox().stop()`) + `box.pause()`s and **propagates** failures (the contract says provider errors must reject — keep-alive + boxes can't pause and will reject); `shutdown` (server stop) is the same pause but failure-tolerated. + Both leave the box reattachable. `keepAlive` defaults to **false** (pause-based idle; `true` can't be + paused and runs until deleted). **Path bridge:** Eve roots its tools at `/workspace` but Box sessions live in `/workspace/home`, so the backend remaps both `resolvePath` (file ops) and raw commands (`find /workspace …` → `/workspace/home`, URL-safe via lookbehind) through the exported `toBoxPath`/`rewriteWorkspacePaths`. - `gpt-5.4-mini` (demo model) may not exist → demos build fine but can 404 at runtime. Swap if needed. diff --git a/examples/ai-sdk-demo/package.json b/examples/ai-sdk-demo/package.json index f6ae7c8..72b22f2 100644 --- a/examples/ai-sdk-demo/package.json +++ b/examples/ai-sdk-demo/package.json @@ -8,12 +8,12 @@ "start": "next start" }, "dependencies": { - "@ai-sdk/openai": "^4.0.15", - "@ai-sdk/react": "^4.0.33", + "@ai-sdk/openai": "^4.0.37", + "@ai-sdk/react": "^4.0.62", "@upstash/agentkit-ai-sdk": "workspace:*", "@upstash/agentkit-sdk": "workspace:*", "@upstash/redis": "^1.38.0", - "ai": "7.0.30", + "ai": "7.0.58", "dotenv": "^16.4.5", "next": "16.2.9", "react": "19.2.6", diff --git a/examples/eve-demo/app/_components/agent-chat.tsx b/examples/eve-demo/app/_components/agent-chat.tsx index 3233511..c9636d9 100644 --- a/examples/eve-demo/app/_components/agent-chat.tsx +++ b/examples/eve-demo/app/_components/agent-chat.tsx @@ -28,7 +28,7 @@ export function AgentChat({ function send(text: string) { const value = text.trim(); if (!value || busy) return; - void agent.send({ message: value }); + void agent.send(value); setInput(""); } diff --git a/examples/eve-demo/package.json b/examples/eve-demo/package.json index cee6371..beabfe9 100644 --- a/examples/eve-demo/package.json +++ b/examples/eve-demo/package.json @@ -14,14 +14,7 @@ "typecheck": "tsgo --noEmit -p tsconfig.json" }, "dependencies": { - "@ai-sdk/openai": "^4.0.15", - "@upstash/agentkit-eve": "workspace:*", - "@upstash/box": "^0.5.1", - "@upstash/redis": "^1.38.0", - "@vercel/connect": "0.2.2", - "ai": "7.0.30", - "eve": "^0.25.2", - "zod": "4.4.3", + "@ai-sdk/openai": "^4.0.37", "@radix-ui/react-use-controllable-state": "1.2.2", "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", @@ -31,9 +24,15 @@ "@streamdown/math": "1.0.2", "@streamdown/mermaid": "1.0.2", "@tailwindcss/postcss": "4.3.0", + "@upstash/agentkit-eve": "workspace:*", + "@upstash/box": "^0.5.1", + "@upstash/redis": "^1.38.0", + "@vercel/connect": "0.2.2", + "ai": "7.0.58", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", + "eve": "^0.32.0", "lucide-react": "1.16.0", "motion": "12.40.0", "nanoid": "5.1.11", @@ -45,13 +44,14 @@ "streamdown": "2.5.0", "tailwind-merge": "3.6.0", "tailwindcss": "4.3.0", - "use-stick-to-bottom": "1.1.4" + "use-stick-to-bottom": "1.1.4", + "zod": "4.4.3" }, "devDependencies": { "@types/node": "24.x", - "@typescript/native-preview": "7.0.0-dev.20260523.1", "@types/react": "19.2.15", - "@types/react-dom": "19.2.3" + "@types/react-dom": "19.2.3", + "@typescript/native-preview": "7.0.0-dev.20260523.1" }, "engines": { "node": "24.x" diff --git a/examples/eve-extension-demo/package.json b/examples/eve-extension-demo/package.json index 471ba61..2164d08 100644 --- a/examples/eve-extension-demo/package.json +++ b/examples/eve-extension-demo/package.json @@ -14,12 +14,12 @@ "typecheck": "tsc" }, "dependencies": { - "@ai-sdk/openai": "^4.0.15", + "@ai-sdk/openai": "^4.0.37", "@upstash/agentkit-eve-extension": "workspace:*", "@upstash/redis": "^1.38.0", "@vercel/connect": "0.2.2", - "ai": "7.0.30", - "eve": "^0.25.2", + "ai": "7.0.58", + "eve": "^0.32.0", "zod": "4.4.3" }, "devDependencies": { diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json index d071021..bbb8206 100644 --- a/packages/ai-sdk/package.json +++ b/packages/ai-sdk/package.json @@ -50,11 +50,11 @@ "zod": "^3.23.8 || ^4" }, "devDependencies": { + "@ai-sdk/openai": "^4.0.37", + "@ai-sdk/provider": "^4.0.7", "@upstash/redis": "^1.38.0", - "dotenv": "^16.4.5", - "ai": "7.0.30", - "@ai-sdk/provider": "^4.0.3", - "@ai-sdk/openai": "^4.0.15" + "ai": "7.0.58", + "dotenv": "^16.4.5" }, "peerDependencies": { "ai": ">=7.0.0-beta" diff --git a/packages/eve-extension/README.md b/packages/eve-extension/README.md index ff63d86..760917b 100644 --- a/packages/eve-extension/README.md +++ b/packages/eve-extension/README.md @@ -15,7 +15,7 @@ no repeated schemas; upgrades come through the package manager. `` is the mount file's basename — the examples below use `agentkit`. -Start from an eve project (eve ≥ 0.25.2), then: +Start from an eve project (eve ≥ 0.32 — the prebuilt extension's compatibility manifest requires 0.32's contribution formats), then: ```bash pnpm add @upstash/agentkit-eve-extension diff --git a/packages/eve-extension/package.json b/packages/eve-extension/package.json index 74ba4bc..ec940a7 100644 --- a/packages/eve-extension/package.json +++ b/packages/eve-extension/package.json @@ -56,7 +56,7 @@ }, "devDependencies": { "@types/node": "24.x", - "eve": "^0.25.2", + "eve": "^0.32.0", "typescript": "7.0.2" }, "peerDependencies": { diff --git a/packages/eve/package.json b/packages/eve/package.json index e02fdf8..5ee2367 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -56,9 +56,9 @@ "devDependencies": { "@upstash/box": "^0.5.1", "@upstash/redis": "^1.38.0", - "ai": "7.0.30", + "ai": "7.0.58", "dotenv": "^16.4.5", - "eve": "^0.25.2" + "eve": "^0.32.0" }, "peerDependencies": { "@upstash/box": ">=0.5.0", diff --git a/packages/eve/src/index.ts b/packages/eve/src/index.ts index 43f7f56..e87cc71 100644 --- a/packages/eve/src/index.ts +++ b/packages/eve/src/index.ts @@ -1,6 +1,6 @@ // Cached tools — like Eve's defineTool, but the result is memoized in an Upstash ToolCache export { defineCachedTool } from "./tools.js"; -export type { CacheUserId, DefineCachedToolConfig } from "./tools.js"; +export type { CacheUserId, DefineCachedToolConfig, ResolvedToolDefinition } from "./tools.js"; // Long-term memory as Eve tools (drop into agent/tools/*.ts) export { defineMemoryRecallTool, defineMemorySaveTool } from "./memory.js"; diff --git a/packages/eve/src/memory.ts b/packages/eve/src/memory.ts index ed19e85..063f744 100644 --- a/packages/eve/src/memory.ts +++ b/packages/eve/src/memory.ts @@ -2,7 +2,8 @@ import { z } from "zod"; import { AgentMemory } from "@upstash/agentkit-sdk"; import { Redis } from "@upstash/redis"; import { defineTool } from "eve/tools"; -import type { ToolContext, ToolDefinition } from "eve/tools"; +import type { ToolContext } from "eve/tools"; +import type { ResolvedToolDefinition } from "./tools"; /** * The user the memory is read/written under. A string shares all memory across callers (fine for a @@ -43,7 +44,7 @@ function resolveUserId(config: MemoryToolConfig, input: Record, */ export function defineMemoryRecallTool( config: MemoryToolConfig, -): ToolDefinition<{ query?: string }, { text: string; score: number }[]> { +): ResolvedToolDefinition<{ query?: string }, { text: string; score: number }[]> { const memory = resolveMemory(config); return defineTool({ description: @@ -70,7 +71,7 @@ export function defineMemoryRecallTool( }); return hits.map((h) => ({ text: h.text, score: h.score })); }, - } as Parameters[0]) as ToolDefinition< + } as Parameters[0]) as ResolvedToolDefinition< { query?: string }, { text: string; score: number }[] >; @@ -87,7 +88,7 @@ export function defineMemoryRecallTool( */ export function defineMemorySaveTool( config: MemoryToolConfig, -): ToolDefinition<{ text: string }, { id: string; saved: boolean }> { +): ResolvedToolDefinition<{ text: string }, { id: string; saved: boolean }> { const memory = resolveMemory(config); return defineTool({ description: @@ -100,7 +101,7 @@ export function defineMemorySaveTool( const record = await memory.add({ text, userId: resolveUserId(config, { text }, ctx) }); return { id: record.id, saved: true }; }, - } as Parameters[0]) as ToolDefinition< + } as Parameters[0]) as ResolvedToolDefinition< { text: string }, { id: string; saved: boolean } >; diff --git a/packages/eve/src/sandbox.ts b/packages/eve/src/sandbox.ts index 92c89e7..7a76df8 100644 --- a/packages/eve/src/sandbox.ts +++ b/packages/eve/src/sandbox.ts @@ -510,15 +510,23 @@ export class UpstashSandboxBackend implements SandboxBackend< }, }); + // Eve calls `stop` when authored code ends sandbox work early (`ctx.getSandbox().stop()`, + // eve ≥0.32): stop the compute but keep the session reattachable from `captureState`'s `boxId` + // (`openBox` reattaches via `Box.get`). Pausing does exactly that. Per the contract, provider + // errors must reject — so no catch here; keep-alive boxes can't be paused and will reject. + const stop = async (): Promise => { + await box.pause(); + }; + // Eve calls `shutdown` only when the server itself is stopping (SIGINT/SIGTERM/nitro close): - // nothing may be left running, but the box must stay reattachable from `captureState`'s `boxId` - // on the next start. Pausing does exactly that (`openBox` reattaches via `Box.get`). Keep-alive - // boxes can't be paused — tolerate the failure, matching Eve's own Vercel backend's try/catch. + // nothing may be left running, but the box must stay reattachable on the next start — same + // pause as `stop`, except failures are tolerated (keep-alive boxes can't pause; eve collects + // and logs shutdown failures rather than blocking teardown). const shutdown = async (): Promise => { await box.pause().catch(() => {}); }; - return { session, useSessionFn, captureState, shutdown }; + return { session, useSessionFn, captureState, stop, shutdown }; } async prewarm( diff --git a/packages/eve/src/search-tools.ts b/packages/eve/src/search-tools.ts index baa2145..e4fc8e3 100644 --- a/packages/eve/src/search-tools.ts +++ b/packages/eve/src/search-tools.ts @@ -5,7 +5,7 @@ import { } from "@upstash/agentkit-sdk"; import { Redis } from "@upstash/redis"; import { defineTool } from "eve/tools"; -import type { ToolDefinition } from "eve/tools"; +import type { ResolvedToolDefinition } from "./tools"; export interface DefineSearchToolsConfig extends Omit { /** Upstash Redis client. Defaults to `Redis.fromEnv()`. */ @@ -14,17 +14,17 @@ export interface DefineSearchToolsConfig extends Omit; + aggregate: ResolvedToolDefinition; + count: ResolvedToolDefinition; } -function wrap(def: SearchToolDef): ToolDefinition { +function wrap(def: SearchToolDef): ResolvedToolDefinition { return defineTool({ description: def.description, inputSchema: def.inputSchema, execute: (input: Record) => def.execute(input), - } as Parameters[0]) as ToolDefinition; + } as Parameters[0]) as ResolvedToolDefinition; } /** diff --git a/packages/eve/src/tools.ts b/packages/eve/src/tools.ts index ed220ad..7ae21d0 100644 --- a/packages/eve/src/tools.ts +++ b/packages/eve/src/tools.ts @@ -6,6 +6,18 @@ import type { ToolContext, ToolDefinition } from "eve/tools"; /** The user a cache entry is scoped to: a fixed string, or a function of the tool input + context. */ export type CacheUserId = string | ((input: TInput, ctx: ToolContext) => string); +/** + * A {@link ToolDefinition} whose `execute` is known to resolve to `TOutput` — never an + * `AsyncIterable` of output snapshots (eve ≥0.31 lets executors stream; ours never do). + * What the agentkit tool factories return, so direct `execute` calls stay awaitable. + */ +export type ResolvedToolDefinition = Omit< + ToolDefinition, + "execute" +> & { + execute(input: TInput, ctx: ToolContext): Promise; +}; + export type DefineCachedToolConfig = ToolDefinition & { /** Upstash Redis client. Defaults to `Redis.fromEnv()`. */ redis?: Redis; @@ -23,6 +35,10 @@ export type DefineCachedToolConfig = ToolDefinition::`. * + * A cached tool does not stream: if `execute` is an async generator (eve ≥0.31's preliminary output + * snapshots), it is drained and only the final snapshot is cached and returned — a cache hit could + * never replay the intermediate snapshots anyway. + * * ```ts * // agent/tools/get_weather.ts * import { z } from "zod"; @@ -39,7 +55,7 @@ export type DefineCachedToolConfig = ToolDefinition( config: DefineCachedToolConfig, -): ToolDefinition { +): ResolvedToolDefinition { const { redis, toolName, userId, ttlSeconds, execute, ...rest } = config; const cache = new ToolCache({ redis: redis ?? Redis.fromEnv() }); @@ -50,10 +66,34 @@ export function defineCachedTool( const run = cache.wrap( resolvedUserId, toolName, - (i) => Promise.resolve(execute(i, ctx)), + async (i) => finalSnapshot(await execute(i, ctx)), ttlSeconds !== undefined ? { ttlSeconds } : {}, ); return run(input); }, - } as Parameters[0]) as ToolDefinition; + } as Parameters[0]) as ResolvedToolDefinition; +} + +function isAsyncIterable(value: T | AsyncIterable): value is AsyncIterable { + return ( + typeof value === "object" && + value !== null && + Symbol.asyncIterator in value && + typeof (value as AsyncIterable)[Symbol.asyncIterator] === "function" + ); +} + +/** Resolve an eve ≥0.31 streaming `execute` result to the value to cache: its last yielded snapshot. */ +async function finalSnapshot(result: TOutput | AsyncIterable): Promise { + if (!isAsyncIterable(result)) return result; + let last: TOutput | undefined; + let yielded = false; + for await (const snapshot of result) { + last = snapshot; + yielded = true; + } + if (!yielded) { + throw new Error("defineCachedTool: streaming execute completed without yielding a result"); + } + return last as TOutput; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 500aadd..f0447fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,11 +45,11 @@ importers: examples/ai-sdk-demo: dependencies: '@ai-sdk/openai': - specifier: ^4.0.15 - version: 4.0.15(zod@4.4.3) + specifier: ^4.0.37 + version: 4.0.37(zod@4.4.3) '@ai-sdk/react': - specifier: ^4.0.33 - version: 4.0.33(react@19.2.6)(zod@4.4.3) + specifier: ^4.0.62 + version: 4.0.62(react@19.2.6)(zod@4.4.3) '@upstash/agentkit-ai-sdk': specifier: workspace:* version: link:../../packages/ai-sdk @@ -60,8 +60,8 @@ importers: specifier: ^1.38.0 version: 1.38.0 ai: - specifier: 7.0.30 - version: 7.0.30(zod@4.4.3) + specifier: 7.0.58 + version: 7.0.58(zod@4.4.3) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -94,8 +94,8 @@ importers: examples/eve-demo: dependencies: '@ai-sdk/openai': - specifier: ^4.0.15 - version: 4.0.15(zod@4.4.3) + specifier: ^4.0.37 + version: 4.0.37(zod@4.4.3) '@radix-ui/react-use-controllable-state': specifier: 1.2.2 version: 1.2.2(@types/react@19.2.15)(react@19.2.6) @@ -134,10 +134,10 @@ importers: version: 1.38.0 '@vercel/connect': specifier: 0.2.2 - version: 0.2.2(@ai-sdk/mcp@2.0.14(zod@4.4.3))(ai@7.0.30(zod@4.4.3))(eve@0.25.2(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.30(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0))) + version: 0.2.2(@ai-sdk/mcp@2.0.30(zod@4.4.3))(ai@7.0.58(zod@4.4.3))(eve@0.32.0(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.58(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0))) ai: - specifier: 7.0.30 - version: 7.0.30(zod@4.4.3) + specifier: 7.0.58 + version: 7.0.58(zod@4.4.3) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -148,8 +148,8 @@ importers: specifier: 1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) eve: - specifier: ^0.25.2 - version: 0.25.2(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.30(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) + specifier: ^0.32.0 + version: 0.32.0(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.58(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) lucide-react: specifier: 1.16.0 version: 1.16.0(react@19.2.6) @@ -206,8 +206,8 @@ importers: examples/eve-extension-demo: dependencies: '@ai-sdk/openai': - specifier: ^4.0.15 - version: 4.0.15(zod@4.4.3) + specifier: ^4.0.37 + version: 4.0.37(zod@4.4.3) '@upstash/agentkit-eve-extension': specifier: workspace:* version: link:../../packages/eve-extension @@ -216,13 +216,13 @@ importers: version: 1.38.0 '@vercel/connect': specifier: 0.2.2 - version: 0.2.2(@ai-sdk/mcp@2.0.14(zod@4.4.3))(ai@7.0.30(zod@4.4.3))(eve@0.25.2(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.30(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0))) + version: 0.2.2(@ai-sdk/mcp@2.0.30(zod@4.4.3))(ai@7.0.58(zod@4.4.3))(eve@0.32.0(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.58(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0))) ai: - specifier: 7.0.30 - version: 7.0.30(zod@4.4.3) + specifier: 7.0.58 + version: 7.0.58(zod@4.4.3) eve: - specifier: ^0.25.2 - version: 0.25.2(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.30(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) + specifier: ^0.32.0 + version: 0.32.0(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.58(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) zod: specifier: 4.4.3 version: 4.4.3 @@ -247,14 +247,14 @@ importers: version: 4.4.3 devDependencies: '@ai-sdk/openai': - specifier: ^4.0.15 - version: 4.0.15(zod@4.4.3) + specifier: ^4.0.37 + version: 4.0.37(zod@4.4.3) '@ai-sdk/provider': - specifier: ^4.0.3 - version: 4.0.3 + specifier: ^4.0.7 + version: 4.0.7 ai: - specifier: 7.0.30 - version: 7.0.30(zod@4.4.3) + specifier: 7.0.58 + version: 7.0.58(zod@4.4.3) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -278,14 +278,14 @@ importers: specifier: ^1.38.0 version: 1.38.0 ai: - specifier: 7.0.30 - version: 7.0.30(zod@4.4.3) + specifier: 7.0.58 + version: 7.0.58(zod@4.4.3) dotenv: specifier: ^16.4.5 version: 16.6.1 eve: - specifier: ^0.25.2 - version: 0.25.2(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.30(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) + specifier: ^0.32.0 + version: 0.32.0(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.58(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) packages/eve-extension: dependencies: @@ -303,8 +303,8 @@ importers: specifier: 24.x version: 24.13.2 eve: - specifier: ^0.25.2 - version: 0.25.2(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.30(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) + specifier: ^0.32.0 + version: 0.32.0(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.59(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) typescript: specifier: 7.0.2 version: 7.0.2 @@ -327,36 +327,48 @@ importers: packages: - '@ai-sdk/gateway@4.0.22': - resolution: {integrity: sha512-KdXJLa6O25fltJC/Lh8gnB1VaYMdX2mEKIalkohuItS+Ig523XxwK9e/Uuc66AvtrLIf7G51cDg3rDpP9mrxwQ==} + '@ai-sdk/gateway@4.0.46': + resolution: {integrity: sha512-LIAO6kAG8fpXQb9L0iwPk1FIbXftvqnyC56v5NEAzeWTeL8fUsy/Hx86VPBTWEDFdwbVprjWifJOAqS6AOj3mA==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/mcp@2.0.14': - resolution: {integrity: sha512-Vf2THTWylnOiPUZgPEzTcbin+6pIBKMdJnDNAMHD0u+upNoO5747HMMFUJJnNNjk8eroAPomaVmF0w8gadA4WA==} + '@ai-sdk/gateway@4.0.47': + resolution: {integrity: sha512-BuWWlA8atWEnEJzN4eamCpGZWkoK9V3TCyNaMwgRIazhqkgWkA1Wp36si3j2JeVYzlvlEAOWrJ92dr92j/Vkqg==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@4.0.15': - resolution: {integrity: sha512-JpTLQp5RUbRcs5nOyPEu5NRdxZLUnD/uCyT3qzy26D+iunCeL7KJV58ER9kwisAKnTjWravfNblaQNiWr20M9A==} + '@ai-sdk/mcp@2.0.30': + resolution: {integrity: sha512-blXjM1TVI6sYER6PuMd8ZM+nTq57LUHq4Cc5gmFw1ub9204rALpew0iiusYKjgyaEtCuUcFr/ybBr6RavqV/2A==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@5.0.10': - resolution: {integrity: sha512-uPyec0+85dwxZYXtb8qe8gCjhjDfxP4LCDo/uRQS/iG+FIgYbHPRhr/ys281udG90bTaE18+5cxWraYaf8oHCw==} + '@ai-sdk/openai@4.0.37': + resolution: {integrity: sha512-rQIdkvqHaqyu94zMJKkhucRwsyTpYUmI+vrH/Dm1Erc6ypURikOV+9e51Pni6RL8IxFlu2bv6y8hA1ghzCy7Gw==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider@4.0.3': - resolution: {integrity: sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==} + '@ai-sdk/provider-utils@5.0.25': + resolution: {integrity: sha512-xscPPHCSjCHWrdhai25sbHCJeKNLW/3D1uSpUZa4cEtTKXA8OnPQ3+Rfu1SmM5Ea/Mf8Dfn3cllw9zeMzo/zFA==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.26': + resolution: {integrity: sha512-0mqhqx+Dcv+msI84+zkbLXmnCHb/mpkYJ9DU3HBvB1px+UjFQss4lLZqRCSzJ0w3NxwQk4Ishk7cP0r8wEQD5w==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@4.0.7': + resolution: {integrity: sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==} engines: {node: '>=22'} - '@ai-sdk/react@4.0.33': - resolution: {integrity: sha512-THPfbAeW8kJo6x3rBbovVhUJA5AyUwKjQ8qehQ3qciD9BwJ9dnPl5yt3P5tH26WEWoZdgPbtK+NRu4UR9NV3yw==} + '@ai-sdk/react@4.0.62': + resolution: {integrity: sha512-Fe3PZmbSzBBmVDE4+HqHJ3x6mYhZ8riUR+3fyjSY+xiRuAntkVkH/MTh0kylvteeRlpgNA4tDDFOTiZfcLPbgg==} engines: {node: '>=22'} peerDependencies: react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 @@ -2846,8 +2858,14 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ai@7.0.30: - resolution: {integrity: sha512-tm0gTAHSWBdDfW4P2mj+LlglGCUQTSA9ktyS2PsPgVhxNO9gYWTSnRAehiJ3h1lYsJBp1Zgbz3RH2lezJXagiw==} + ai@7.0.58: + resolution: {integrity: sha512-GfgO90CQQ0yYuoxJAUOeQ6tviyYw1BUIDygSZ1q3Ce6kSc93tYmB5eltKY/NxC0YOouAax7JvDqVnYxvIAr04Q==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + ai@7.0.59: + resolution: {integrity: sha512-p10cqg8KvLIZi7Gk+XsQjoYoLENoDdEYvehP0rSo6Wg15nbCmP+nwIy1rOIX+WGIDWGdI+jmhjVx5fXShBVC3w==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -3375,13 +3393,13 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - eve@0.25.2: - resolution: {integrity: sha512-TT6zdOfUSZNte9qWpGOGlzVpz91lIRFFpSxe+feTrxWnUlY5jNnUo46zt3AmRIdlauIlIw2k5JU5kQB2hUO57w==} + eve@0.32.0: + resolution: {integrity: sha512-b1+JLZIAucpW6eVxdyLKlKp0sJ20F737TEe+wj6vYzhq5JPz5n9bdoTCvh5Z1Q7TFGLO0GSIub/q9b8UGVb28Q==} engines: {node: '>=24'} hasBin: true peerDependencies: '@opentelemetry/api': ^1.0.0 - ai: ^7.0.26 + ai: ^7.0.58 braintrust: ^3.0.0 just-bash: ^3.0.0 microsandbox: ^0.5.0 @@ -3399,6 +3417,10 @@ packages: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -4839,6 +4861,14 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + undici@8.9.0: + resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -5099,44 +5129,61 @@ packages: snapshots: - '@ai-sdk/gateway@4.0.22(zod@4.4.3)': + '@ai-sdk/gateway@4.0.46(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.7 + '@ai-sdk/provider-utils': 5.0.25(zod@4.4.3) + '@vercel/oidc': 3.2.0 + zod: 4.4.3 + + '@ai-sdk/gateway@4.0.47(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 4.0.3 - '@ai-sdk/provider-utils': 5.0.10(zod@4.4.3) + '@ai-sdk/provider': 4.0.7 + '@ai-sdk/provider-utils': 5.0.26(zod@4.4.3) '@vercel/oidc': 3.2.0 zod: 4.4.3 - '@ai-sdk/mcp@2.0.14(zod@4.4.3)': + '@ai-sdk/mcp@2.0.30(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 4.0.3 - '@ai-sdk/provider-utils': 5.0.10(zod@4.4.3) + '@ai-sdk/provider': 4.0.7 + '@ai-sdk/provider-utils': 5.0.26(zod@4.4.3) pkce-challenge: 5.0.1 zod: 4.4.3 - '@ai-sdk/openai@4.0.15(zod@4.4.3)': + '@ai-sdk/openai@4.0.37(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 4.0.3 - '@ai-sdk/provider-utils': 5.0.10(zod@4.4.3) + '@ai-sdk/provider': 4.0.7 + '@ai-sdk/provider-utils': 5.0.26(zod@4.4.3) zod: 4.4.3 - '@ai-sdk/provider-utils@5.0.10(zod@4.4.3)': + '@ai-sdk/provider-utils@5.0.25(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 4.0.3 + '@ai-sdk/provider': 4.0.7 '@standard-schema/spec': 1.1.0 '@workflow/serde': 4.1.0 eventsource-parser: 3.1.0 + undici: 7.29.0 + zod: 4.4.3 + + '@ai-sdk/provider-utils@5.0.26(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.7 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.1 + undici: 7.29.0 zod: 4.4.3 - '@ai-sdk/provider@4.0.3': + '@ai-sdk/provider@4.0.7': dependencies: json-schema: 0.4.0 - '@ai-sdk/react@4.0.33(react@19.2.6)(zod@4.4.3)': + '@ai-sdk/react@4.0.62(react@19.2.6)(zod@4.4.3)': dependencies: - '@ai-sdk/mcp': 2.0.14(zod@4.4.3) - '@ai-sdk/provider': 4.0.3 - '@ai-sdk/provider-utils': 5.0.10(zod@4.4.3) - ai: 7.0.30(zod@4.4.3) + '@ai-sdk/mcp': 2.0.30(zod@4.4.3) + '@ai-sdk/provider': 4.0.7 + '@ai-sdk/provider-utils': 5.0.26(zod@4.4.3) + ai: 7.0.59(zod@4.4.3) react: 19.2.6 swr: 2.4.1(react@19.2.6) throttleit: 2.1.0 @@ -7311,13 +7358,13 @@ snapshots: dependencies: execa: 5.1.1 - '@vercel/connect@0.2.2(@ai-sdk/mcp@2.0.14(zod@4.4.3))(ai@7.0.30(zod@4.4.3))(eve@0.25.2(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.30(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)))': + '@vercel/connect@0.2.2(@ai-sdk/mcp@2.0.30(zod@4.4.3))(ai@7.0.58(zod@4.4.3))(eve@0.32.0(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.58(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)))': dependencies: '@vercel/oidc': 3.6.1 optionalDependencies: - '@ai-sdk/mcp': 2.0.14(zod@4.4.3) - ai: 7.0.30(zod@4.4.3) - eve: 0.25.2(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.30(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) + '@ai-sdk/mcp': 2.0.30(zod@4.4.3) + ai: 7.0.58(zod@4.4.3) + eve: 0.32.0(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.58(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) '@vercel/oidc@3.2.0': {} @@ -7375,11 +7422,18 @@ snapshots: acorn@8.17.0: {} - ai@7.0.30(zod@4.4.3): + ai@7.0.58(zod@4.4.3): dependencies: - '@ai-sdk/gateway': 4.0.22(zod@4.4.3) - '@ai-sdk/provider': 4.0.3 - '@ai-sdk/provider-utils': 5.0.10(zod@4.4.3) + '@ai-sdk/gateway': 4.0.46(zod@4.4.3) + '@ai-sdk/provider': 4.0.7 + '@ai-sdk/provider-utils': 5.0.25(zod@4.4.3) + zod: 4.4.3 + + ai@7.0.59(zod@4.4.3): + dependencies: + '@ai-sdk/gateway': 4.0.47(zod@4.4.3) + '@ai-sdk/provider': 4.0.7 + '@ai-sdk/provider-utils': 5.0.26(zod@4.4.3) zod: 4.4.3 ajv@6.15.0: @@ -7930,10 +7984,58 @@ snapshots: esutils@2.0.3: {} - eve@0.25.2(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.30(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)): + eve@0.32.0(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.58(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)): dependencies: - ai: 7.0.30(zod@4.4.3) + ai: 7.0.58(zod@4.4.3) nitro: 3.0.260610-beta(@upstash/redis@1.38.0)(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) + undici: 8.9.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@netlify/runtime' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vercel/queue' + - aws4fetch + - better-sqlite3 + - chokidar + - dotenv + - drizzle-orm + - giget + - idb-keyval + - ioredis + - jiti + - lru-cache + - miniflare + - mongodb + - mysql2 + - rollup + - sqlite3 + - uploadthing + - vite + - wrangler + - xml2js + - zephyr-agent + + eve@0.32.0(@opentelemetry/api@1.9.1)(@upstash/redis@1.38.0)(ai@7.0.59(zod@4.4.3))(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)): + dependencies: + ai: 7.0.59(zod@4.4.3) + nitro: 3.0.260610-beta(@upstash/redis@1.38.0)(chokidar@4.0.3)(dotenv@16.6.1)(jiti@2.7.0)(rollup@4.62.0)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) + undici: 8.9.0 optionalDependencies: '@opentelemetry/api': 1.9.1 transitivePeerDependencies: @@ -7978,6 +8080,8 @@ snapshots: eventsource-parser@3.1.0: {} + eventsource-parser@3.1.1: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -9813,6 +9917,10 @@ snapshots: undici-types@7.18.2: {} + undici@7.29.0: {} + + undici@8.9.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 From 0968f7df763fde141f349110660c2fa56c7ea1de Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 13 Aug 2026 09:20:57 +0300 Subject: [PATCH 2/4] test(eve): cover defineCachedTool with async-generator executors Drain-to-final-snapshot resolution, single-run caching of the drained value, and the empty-generator rejection not poisoning the cache. --- packages/eve/src/tools.test.ts | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/eve/src/tools.test.ts b/packages/eve/src/tools.test.ts index 9554422..b0783ea 100644 --- a/packages/eve/src/tools.test.ts +++ b/packages/eve/src/tools.test.ts @@ -45,4 +45,70 @@ describe.skipIf(!hasRedisCreds)("defineCachedTool (live Redis)", () => { await t.execute({ id: "a" }, CTX); expect(fn).toHaveBeenCalledTimes(1); }); + + // eve ≥0.31 lets `execute` be an async generator that streams preliminary output snapshots. + // A cached tool drains it: only the final snapshot is returned and cached (`finalSnapshot`). + describe("streaming (async generator) execute", () => { + it("resolves to the final snapshot, not an intermediate one", async () => { + const t = defineCachedTool({ + description: "stream", + inputSchema: z.object({ n: z.number() }), + toolName: "stream-final", + userId: ns, + redis, + async *execute({ n }: { n: number }) { + yield { progress: "partial", value: n }; + yield { progress: "partial", value: n * 10 }; + yield { progress: "done", value: n * 100 }; + }, + }); + + expect(await t.execute({ n: 3 }, CTX)).toEqual({ progress: "done", value: 300 }); + }); + + it("caches the drained snapshot so the generator runs once", async () => { + const runs = vi.fn(); + const t = defineCachedTool({ + description: "stream", + inputSchema: z.object({ n: z.number() }), + toolName: "stream-cached", + userId: ns, + redis, + async *execute({ n }: { n: number }) { + runs(); + yield n; + yield n * 2; + }, + }); + + expect(await t.execute({ n: 21 }, CTX)).toBe(42); + expect(await t.execute({ n: 21 }, CTX)).toBe(42); + expect(runs).toHaveBeenCalledTimes(1); + }); + + it("rejects when the generator yields nothing, without caching the failure", async () => { + let broken = true; + const runs = vi.fn(); + const t = defineCachedTool({ + description: "stream", + inputSchema: z.object({}), + toolName: "stream-empty", + userId: ns, + redis, + async *execute() { + runs(); + if (!broken) yield "ok"; + }, + }); + + await expect(t.execute({}, CTX)).rejects.toThrow(/without yielding a result/); + + // The failed call must not have poisoned the cache: once the tool yields, its + // result comes from a fresh run (2 executions total), then caches normally. + broken = false; + expect(await t.execute({}, CTX)).toBe("ok"); + expect(await t.execute({}, CTX)).toBe("ok"); + expect(runs).toHaveBeenCalledTimes(2); + }); + }); }); From e8d3058ab316c5f6dee65d47c8f279cdfea8f8f4 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 13 Aug 2026 10:17:17 +0300 Subject: [PATCH 3/4] refactor(eve)!: reject streaming executors in defineCachedTool instead of draining MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cached tools don't stream — narrow DefineCachedToolConfig.execute (Promise | NonStreaming) so async-generator executors fail to compile, rather than accepting and draining them at runtime. The NonStreaming ([Symbol.asyncIterator]?: never) intersection is required: with a plain union TS infers TOutput as the generator itself. Drops the finalSnapshot/isAsyncIterable runtime path, the streaming tests (replaced by a @ts-expect-error type guard), and the ResolvedToolDefinition export — factory returns are plain ToolDefinition again; direct execute callers narrow the awaited union themselves. --- .changeset/eve-032-upgrade.md | 11 ++--- CLAUDE.md | 21 +++++---- packages/eve/src/index.ts | 2 +- packages/eve/src/memory.test.ts | 12 ++++- packages/eve/src/memory.ts | 11 +++-- packages/eve/src/search-tools.ts | 12 ++--- packages/eve/src/tools.test.ts | 76 ++++++-------------------------- packages/eve/src/tools.ts | 54 +++++++---------------- 8 files changed, 66 insertions(+), 133 deletions(-) diff --git a/.changeset/eve-032-upgrade.md b/.changeset/eve-032-upgrade.md index afaddb6..1636fc8 100644 --- a/.changeset/eve-032-upgrade.md +++ b/.changeset/eve-032-upgrade.md @@ -10,13 +10,10 @@ Upgrade to eve 0.32 (repo now builds and tests against eve 0.32.0 / AI SDK 7.0.5 - The Upstash Box sandbox backend implements eve ≥0.32's `SandboxBackendHandle.stop()` (authored `ctx.getSandbox().stop()`): pauses the box, keeps the session reattachable, and rejects on provider errors per the contract (`shutdown()` stays best-effort). -- New exported type `ResolvedToolDefinition`: eve ≥0.31 widened - `ToolDefinition.execute`'s return type to include `AsyncIterable` (streaming output - snapshots); all agentkit tool factories (`defineCachedTool`, `defineMemoryRecallTool`, - `defineMemorySaveTool`, `defineSearchTools`) now return this narrowed type, so calling `execute` - directly still resolves to a plain `Promise`. -- `defineCachedTool` handles streaming executors: an async-generator `execute` is drained and only its - final snapshot is cached and returned (a cache hit cannot replay intermediate snapshots). +- `defineCachedTool` does not cache streams: eve ≥0.31 lets tool executors be async generators + (streaming preliminary output snapshots), but a cache hit could never replay them — + `DefineCachedToolConfig` now rejects async-generator executors at the type level (its `execute` + must resolve to a value). `@upstash/agentkit-eve-extension`: diff --git a/CLAUDE.md b/CLAUDE.md index fb38fbc..88e7d14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,14 +76,16 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). conversations. That's lookup-on-demand, not session resume: the same no-round-trip caveat holds.) - `./sandbox` → `upstash()` Upstash Box backend. **⚠ INCOMPLETE — see Known issues.** - Eve is file-centric, but the tool factories now **call `defineTool` internally** and return the - branded definition — users export them directly (no outer `defineTool(...)` wrap). Because of - this, **`eve` is a required (non-optional) peer dep** of `packages/eve`. The factories return - **`ResolvedToolDefinition`** (exported from the root): eve ≥0.31 widened - `ToolDefinition.execute`'s return to `Promise | TOutput | AsyncIterable` (streaming - output snapshots), so this `Omit`-based type re-narrows `execute` to `Promise` for direct - callers (a plain intersection does NOT override — TS picks the union signature first). -- **`defineCachedTool` does not stream:** if the user's `execute` is an async generator, it's drained - and only the **final snapshot** is cached and returned (a cache hit can't replay a stream anyway). + branded `ToolDefinition` — users export them directly (no outer `defineTool(...)` wrap). Because of + this, **`eve` is a required (non-optional) peer dep** of `packages/eve`. +- **`defineCachedTool` does not cache streams:** eve ≥0.31 lets executors be async generators + (preliminary output snapshots), but a cache hit could never replay them — so + `DefineCachedToolConfig` narrows the **input** `execute` to `Promise | NonStreaming` + and rejects generator executors at the type level. The `NonStreaming` (`[Symbol.asyncIterator]?: never`) + intersection is load-bearing: with a plain `Promise | TOutput` union, TS just infers + `TOutput` *as* the generator object and the rejection silently fails (guarded by a + `@ts-expect-error` test in `tools.test.ts`). Factory **returns** stay plain `ToolDefinition` — direct + `execute` callers (tests) narrow the awaited union themselves. - Rate limiting in eve = a route-auth gate: `createRateLimitAuth(config)` goes first in `eveChannel({ auth: [...] })`; it `.limit()`s, throws `ForbiddenError` (403) over the limit, else returns `null` to fall through to the real authenticators (`localDev()`/`vercelOidc()`/…). @@ -243,7 +245,8 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). (`agent.send(message, options?)`, not `send({ message })`; eve-demo's `agent-chat.tsx` was updated); (b) `SandboxBackendHandle` gained a required **`stop()`** (authored-runtime stop, errors must reject) alongside `shutdown()`; (c) tool executors may return **`AsyncIterable`** (streaming output - snapshots, 0.31) — `ToolDefinition.execute`'s return type is now a union, see `ResolvedToolDefinition`; + snapshots, 0.31) — `ToolDefinition.execute`'s return type is now a union; `defineCachedTool` rejects + streaming executors at the type level (see the eve exports section); (d) 0.30 changed `localDev()` to grant a deployment-based synthetic principal (runtime `principalId` values differ in local dev; our sanitizing `resolveUserId` is unaffected); (e) eve 0.32's `ai` peer is `^7.0.58` (drove the repo-wide exact-pin bump). Durable sessions now **complete after 30 days** by diff --git a/packages/eve/src/index.ts b/packages/eve/src/index.ts index e87cc71..43f7f56 100644 --- a/packages/eve/src/index.ts +++ b/packages/eve/src/index.ts @@ -1,6 +1,6 @@ // Cached tools — like Eve's defineTool, but the result is memoized in an Upstash ToolCache export { defineCachedTool } from "./tools.js"; -export type { CacheUserId, DefineCachedToolConfig, ResolvedToolDefinition } from "./tools.js"; +export type { CacheUserId, DefineCachedToolConfig } from "./tools.js"; // Long-term memory as Eve tools (drop into agent/tools/*.ts) export { defineMemoryRecallTool, defineMemorySaveTool } from "./memory.js"; diff --git a/packages/eve/src/memory.test.ts b/packages/eve/src/memory.test.ts index a10bcbb..80e1196 100644 --- a/packages/eve/src/memory.test.ts +++ b/packages/eve/src/memory.test.ts @@ -24,11 +24,19 @@ describe.skipIf(!hasRedisCreds)("memory tools (live Redis)", () => { }); it("save then recall round-trips through AgentMemory", async () => { - const saved = await save.execute({ text: "The user prefers dark mode" }, CTX); + // eve ≥0.31 types `execute` as possibly returning an AsyncIterable of output snapshots; + // our executors always resolve, so narrow the awaited results back to their plain values. + const saved = (await save.execute({ text: "The user prefers dark mode" }, CTX)) as { + id: string; + saved: boolean; + }; expect(saved.saved).toBe(true); await index.waitIndexing(); - const hits = await recall.execute({ query: "ui theme preference" }, CTX); + const hits = (await recall.execute({ query: "ui theme preference" }, CTX)) as { + text: string; + score: number; + }[]; expect(hits.some((h) => h.text.includes("dark mode"))).toBe(true); }); }); diff --git a/packages/eve/src/memory.ts b/packages/eve/src/memory.ts index 063f744..ed19e85 100644 --- a/packages/eve/src/memory.ts +++ b/packages/eve/src/memory.ts @@ -2,8 +2,7 @@ import { z } from "zod"; import { AgentMemory } from "@upstash/agentkit-sdk"; import { Redis } from "@upstash/redis"; import { defineTool } from "eve/tools"; -import type { ToolContext } from "eve/tools"; -import type { ResolvedToolDefinition } from "./tools"; +import type { ToolContext, ToolDefinition } from "eve/tools"; /** * The user the memory is read/written under. A string shares all memory across callers (fine for a @@ -44,7 +43,7 @@ function resolveUserId(config: MemoryToolConfig, input: Record, */ export function defineMemoryRecallTool( config: MemoryToolConfig, -): ResolvedToolDefinition<{ query?: string }, { text: string; score: number }[]> { +): ToolDefinition<{ query?: string }, { text: string; score: number }[]> { const memory = resolveMemory(config); return defineTool({ description: @@ -71,7 +70,7 @@ export function defineMemoryRecallTool( }); return hits.map((h) => ({ text: h.text, score: h.score })); }, - } as Parameters[0]) as ResolvedToolDefinition< + } as Parameters[0]) as ToolDefinition< { query?: string }, { text: string; score: number }[] >; @@ -88,7 +87,7 @@ export function defineMemoryRecallTool( */ export function defineMemorySaveTool( config: MemoryToolConfig, -): ResolvedToolDefinition<{ text: string }, { id: string; saved: boolean }> { +): ToolDefinition<{ text: string }, { id: string; saved: boolean }> { const memory = resolveMemory(config); return defineTool({ description: @@ -101,7 +100,7 @@ export function defineMemorySaveTool( const record = await memory.add({ text, userId: resolveUserId(config, { text }, ctx) }); return { id: record.id, saved: true }; }, - } as Parameters[0]) as ResolvedToolDefinition< + } as Parameters[0]) as ToolDefinition< { text: string }, { id: string; saved: boolean } >; diff --git a/packages/eve/src/search-tools.ts b/packages/eve/src/search-tools.ts index e4fc8e3..baa2145 100644 --- a/packages/eve/src/search-tools.ts +++ b/packages/eve/src/search-tools.ts @@ -5,7 +5,7 @@ import { } from "@upstash/agentkit-sdk"; import { Redis } from "@upstash/redis"; import { defineTool } from "eve/tools"; -import type { ResolvedToolDefinition } from "./tools"; +import type { ToolDefinition } from "eve/tools"; export interface DefineSearchToolsConfig extends Omit { /** Upstash Redis client. Defaults to `Redis.fromEnv()`. */ @@ -14,17 +14,17 @@ export interface DefineSearchToolsConfig extends Omit; - aggregate: ResolvedToolDefinition; - count: ResolvedToolDefinition; + search: ToolDefinition; + aggregate: ToolDefinition; + count: ToolDefinition; } -function wrap(def: SearchToolDef): ResolvedToolDefinition { +function wrap(def: SearchToolDef): ToolDefinition { return defineTool({ description: def.description, inputSchema: def.inputSchema, execute: (input: Record) => def.execute(input), - } as Parameters[0]) as ResolvedToolDefinition; + } as Parameters[0]) as ToolDefinition; } /** diff --git a/packages/eve/src/tools.test.ts b/packages/eve/src/tools.test.ts index b0783ea..2f3e983 100644 --- a/packages/eve/src/tools.test.ts +++ b/packages/eve/src/tools.test.ts @@ -46,69 +46,19 @@ describe.skipIf(!hasRedisCreds)("defineCachedTool (live Redis)", () => { expect(fn).toHaveBeenCalledTimes(1); }); - // eve ≥0.31 lets `execute` be an async generator that streams preliminary output snapshots. - // A cached tool drains it: only the final snapshot is returned and cached (`finalSnapshot`). - describe("streaming (async generator) execute", () => { - it("resolves to the final snapshot, not an intermediate one", async () => { - const t = defineCachedTool({ - description: "stream", - inputSchema: z.object({ n: z.number() }), - toolName: "stream-final", - userId: ns, - redis, - async *execute({ n }: { n: number }) { - yield { progress: "partial", value: n }; - yield { progress: "partial", value: n * 10 }; - yield { progress: "done", value: n * 100 }; - }, - }); - - expect(await t.execute({ n: 3 }, CTX)).toEqual({ progress: "done", value: 300 }); - }); - - it("caches the drained snapshot so the generator runs once", async () => { - const runs = vi.fn(); - const t = defineCachedTool({ - description: "stream", - inputSchema: z.object({ n: z.number() }), - toolName: "stream-cached", - userId: ns, - redis, - async *execute({ n }: { n: number }) { - runs(); - yield n; - yield n * 2; - }, - }); - - expect(await t.execute({ n: 21 }, CTX)).toBe(42); - expect(await t.execute({ n: 21 }, CTX)).toBe(42); - expect(runs).toHaveBeenCalledTimes(1); - }); - - it("rejects when the generator yields nothing, without caching the failure", async () => { - let broken = true; - const runs = vi.fn(); - const t = defineCachedTool({ - description: "stream", - inputSchema: z.object({}), - toolName: "stream-empty", - userId: ns, - redis, - async *execute() { - runs(); - if (!broken) yield "ok"; - }, - }); - - await expect(t.execute({}, CTX)).rejects.toThrow(/without yielding a result/); - - // The failed call must not have poisoned the cache: once the tool yields, its - // result comes from a fresh run (2 executions total), then caches normally. - broken = false; - expect(await t.execute({}, CTX)).toBe("ok"); - expect(await t.execute({}, CTX)).toBe("ok"); - expect(runs).toHaveBeenCalledTimes(2); + it("rejects a streaming (async generator) execute at the type level", () => { + // A cached tool cannot stream — a cache hit could never replay eve ≥0.31's preliminary + // output snapshots — so `DefineCachedToolConfig.execute` only accepts resolving executors. + defineCachedTool({ + description: "stream", + inputSchema: z.object({ n: z.number() }), + toolName: "stream", + userId: ns, + redis, + // @ts-expect-error — async-generator executors are not cacheable + async *execute({ n }: { n: number }) { + yield n; + }, }); }); }); diff --git a/packages/eve/src/tools.ts b/packages/eve/src/tools.ts index 7ae21d0..f4b157a 100644 --- a/packages/eve/src/tools.ts +++ b/packages/eve/src/tools.ts @@ -7,18 +7,22 @@ import type { ToolContext, ToolDefinition } from "eve/tools"; export type CacheUserId = string | ((input: TInput, ctx: ToolContext) => string); /** - * A {@link ToolDefinition} whose `execute` is known to resolve to `TOutput` — never an - * `AsyncIterable` of output snapshots (eve ≥0.31 lets executors stream; ours never do). - * What the agentkit tool factories return, so direct `execute` calls stay awaitable. + * A value that is not an `AsyncIterable`. Without the `[Symbol.asyncIterator]: never` block, an + * async-generator executor would satisfy a plain `Promise | TOutput` union by inferring + * `TOutput` *as* the generator object itself. */ -export type ResolvedToolDefinition = Omit< +type NonStreaming = T & { [Symbol.asyncIterator]?: never }; + +export type DefineCachedToolConfig = Omit< ToolDefinition, "execute" > & { - execute(input: TInput, ctx: ToolContext): Promise; -}; - -export type DefineCachedToolConfig = ToolDefinition & { + /** + * The tool body to memoize. Must resolve to a value — a cached tool cannot stream, so eve ≥0.31 + * async-generator executors (preliminary output snapshots) are rejected at the type level: a cache + * hit could never replay the snapshots, it can only return the stored final value. + */ + execute(input: TInput, ctx: ToolContext): Promise | NonStreaming; /** Upstash Redis client. Defaults to `Redis.fromEnv()`. */ redis?: Redis; /** The tool name — the `toolName` segment of the cache key. */ @@ -35,10 +39,6 @@ export type DefineCachedToolConfig = ToolDefinition::`. * - * A cached tool does not stream: if `execute` is an async generator (eve ≥0.31's preliminary output - * snapshots), it is drained and only the final snapshot is cached and returned — a cache hit could - * never replay the intermediate snapshots anyway. - * * ```ts * // agent/tools/get_weather.ts * import { z } from "zod"; @@ -55,7 +55,7 @@ export type DefineCachedToolConfig = ToolDefinition( config: DefineCachedToolConfig, -): ResolvedToolDefinition { +): ToolDefinition { const { redis, toolName, userId, ttlSeconds, execute, ...rest } = config; const cache = new ToolCache({ redis: redis ?? Redis.fromEnv() }); @@ -66,34 +66,10 @@ export function defineCachedTool( const run = cache.wrap( resolvedUserId, toolName, - async (i) => finalSnapshot(await execute(i, ctx)), + (i) => Promise.resolve(execute(i, ctx)), ttlSeconds !== undefined ? { ttlSeconds } : {}, ); return run(input); }, - } as Parameters[0]) as ResolvedToolDefinition; -} - -function isAsyncIterable(value: T | AsyncIterable): value is AsyncIterable { - return ( - typeof value === "object" && - value !== null && - Symbol.asyncIterator in value && - typeof (value as AsyncIterable)[Symbol.asyncIterator] === "function" - ); -} - -/** Resolve an eve ≥0.31 streaming `execute` result to the value to cache: its last yielded snapshot. */ -async function finalSnapshot(result: TOutput | AsyncIterable): Promise { - if (!isAsyncIterable(result)) return result; - let last: TOutput | undefined; - let yielded = false; - for await (const snapshot of result) { - last = snapshot; - yielded = true; - } - if (!yielded) { - throw new Error("defineCachedTool: streaming execute completed without yielding a result"); - } - return last as TOutput; + } as Parameters[0]) as ToolDefinition; } From 863446b21d10e610b5211776145468855aec9930 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 13 Aug 2026 10:19:39 +0300 Subject: [PATCH 4/4] feat(eve): runtime rejection of streaming executors in defineCachedTool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The type-level guard doesn't reach JS callers; without a backstop, ToolCache would serialize the generator object into Redis as the cached result. A directly returned AsyncIterable now throws a TypeError naming the tool. Only direct returns count as streams (matching eve's semantics) — a promise resolving to an iterable is just a value. --- .changeset/eve-032-upgrade.md | 3 ++- CLAUDE.md | 7 +++++-- packages/eve/src/tools.test.ts | 18 ++++++++++++++++++ packages/eve/src/tools.ts | 22 +++++++++++++++++++++- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/.changeset/eve-032-upgrade.md b/.changeset/eve-032-upgrade.md index 1636fc8..dd930e1 100644 --- a/.changeset/eve-032-upgrade.md +++ b/.changeset/eve-032-upgrade.md @@ -13,7 +13,8 @@ Upgrade to eve 0.32 (repo now builds and tests against eve 0.32.0 / AI SDK 7.0.5 - `defineCachedTool` does not cache streams: eve ≥0.31 lets tool executors be async generators (streaming preliminary output snapshots), but a cache hit could never replay them — `DefineCachedToolConfig` now rejects async-generator executors at the type level (its `execute` - must resolve to a value). + must resolve to a value), and a runtime `TypeError` backstops untyped JS callers before the + generator object would be serialized into the cache. `@upstash/agentkit-eve-extension`: diff --git a/CLAUDE.md b/CLAUDE.md index 88e7d14..4e0e24c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,8 +84,11 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). and rejects generator executors at the type level. The `NonStreaming` (`[Symbol.asyncIterator]?: never`) intersection is load-bearing: with a plain `Promise | TOutput` union, TS just infers `TOutput` *as* the generator object and the rejection silently fails (guarded by a - `@ts-expect-error` test in `tools.test.ts`). Factory **returns** stay plain `ToolDefinition` — direct - `execute` callers (tests) narrow the awaited union themselves. + `@ts-expect-error` test in `tools.test.ts`). A **runtime backstop** covers JS callers: a directly + returned `AsyncIterable` throws a `TypeError` before `ToolCache` would serialize the generator + object into Redis (a *promised* value is just a value — only direct returns are streams, matching + eve). Factory **returns** stay plain `ToolDefinition` — direct `execute` callers (tests) narrow the + awaited union themselves. - Rate limiting in eve = a route-auth gate: `createRateLimitAuth(config)` goes first in `eveChannel({ auth: [...] })`; it `.limit()`s, throws `ForbiddenError` (403) over the limit, else returns `null` to fall through to the real authenticators (`localDev()`/`vercelOidc()`/…). diff --git a/packages/eve/src/tools.test.ts b/packages/eve/src/tools.test.ts index 2f3e983..935a9c6 100644 --- a/packages/eve/src/tools.test.ts +++ b/packages/eve/src/tools.test.ts @@ -61,4 +61,22 @@ describe.skipIf(!hasRedisCreds)("defineCachedTool (live Redis)", () => { }, }); }); + + it("rejects a streaming execute at runtime (JS callers bypass the types)", async () => { + const t = defineCachedTool({ + description: "stream", + inputSchema: z.object({ n: z.number() }), + toolName: "stream-runtime", + userId: ns, + redis, + execute: async function* ({ n }: { n: number }) { + yield n; + } as never, // cast past the type-level rejection, like an untyped JS caller + }); + + // The generator must be refused before ToolCache serializes the generator object into Redis. + await expect(Promise.resolve(t.execute({ n: 1 }, CTX))).rejects.toThrow( + /streaming \(async generator\) executors cannot be cached/, + ); + }); }); diff --git a/packages/eve/src/tools.ts b/packages/eve/src/tools.ts index f4b157a..21aeca2 100644 --- a/packages/eve/src/tools.ts +++ b/packages/eve/src/tools.ts @@ -66,10 +66,30 @@ export function defineCachedTool( const run = cache.wrap( resolvedUserId, toolName, - (i) => Promise.resolve(execute(i, ctx)), + (i) => { + const result = execute(i, ctx); + // Backstop for JS callers (the type-level rejection doesn't reach them): an async-generator + // executor returns the generator synchronously — refuse it before ToolCache would serialize + // the generator object into Redis as the "result". Matches eve's streaming semantics: only + // a *directly* returned AsyncIterable is a stream; a promised value is just a value. + if (isAsyncIterable(result)) { + throw new TypeError( + `defineCachedTool("${toolName}"): streaming (async generator) executors cannot be cached — execute must resolve to a value`, + ); + } + return Promise.resolve(result); + }, ttlSeconds !== undefined ? { ttlSeconds } : {}, ); return run(input); }, } as Parameters[0]) as ToolDefinition; } + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return ( + typeof value === "object" && + value !== null && + typeof (value as AsyncIterable)[Symbol.asyncIterator] === "function" + ); +}