From d8b30623576620e48e93b185da3cbb8ebbfb5b56 Mon Sep 17 00:00:00 2001 From: Upstash Box Date: Thu, 29 Oct 2026 09:10:23 +0000 Subject: [PATCH 1/3] chore: bump ai 7.0.156 (not packages/eve), @ai-sdk/react 4.0.160 devDependency/example-only bump; no published package contents change, so no changeset. eve 0.87.0 and TanStack AI 1.0.0 are not applied; see CLAUDE.md. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CMK4etZKNEc4Rzig1SCPpJ --- CLAUDE.md | 1 + examples/ai-sdk-demo/package.json | 4 +- examples/eve-extension-demo/package.json | 2 +- packages/ai-sdk/package.json | 2 +- pnpm-lock.yaml | 56 ++++++++++++------------ 5 files changed, 33 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b4e405..c16df99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1202,6 +1202,7 @@ implements TanStack AI's own backend contracts (see its section below) — keep - **eve 0.83.3 + ai 7.0.154 (2026-10-27): extension + `examples/eve-extension-demo` move to eve `^0.83.3`, ai is `7.0.154` everywhere (`@ai-sdk/react ^4.0.158`).** Same contracts as 0.83.0, peer floor unchanged. **eve 0.84.0 (2026-10-26) is NOT applied: it breaks `packages/eve-extension` and `packages/eve` sources** (needs a source migration). Published `@upstash/agentkit-eve-extension@0.16.1` fails `eve build` on eve 0.84.0 with "uses an old epoch-based distribution manifest … Rebuild the extension with this eve release". Extension typecheck on 0.84.0: `SessionContext` no longer exported from `eve/tools`; hook key `message.received` gone; tool defs no longer accept `events`. `packages/eve` on 0.84.0: `signal` removed from `MemoryDocumentReadInput`/`WriteInput`, `MemoryCompactionCompletedContext` and the `compaction.completed` recall hook gone, `MemoryToolsContext.turn` gone, plus the sandbox typings above. TanStack AI 1.0.0 still not applied (see above); ai 7.0.154 is clean (published `@upstash/agentkit-ai-sdk@0.15.1` typechecks and imports on it). - **eve 0.85.0: the extension IS migrated (extension-only; `packages/eve` + `examples/eve-demo` stay on eve 0.82.0).** eve 0.84.0 replaced per-capability contract epochs with a `{formatVersion: 3, builtWithEve, contractMajor: 0}` manifest and a v27 session-event/dynamic API. Source changes in `packages/eve-extension`: `SessionContext` is gone from `eve/tools` (own `SessionRef = Pick` in `extension/lib/session.ts`, used for the `userId` function and `resolveUserId`); dynamic tools use `defineDynamic(ctx => tool | null)` instead of `defineDynamic({events: {"session.started"}})`; the chat-history hook maps `message.received` -> `delivery.consumed` (text parts of `event.data.parts`) and `message.completed` -> `content.completed` filtered to `kind: "text"` + `phase: "reply"` (one stored assistant message per reply text part; narration is not stored). **The peer floor is `>=0.85.0`**: verified by packing the dist (built with 0.85.0) into consumers on 0.83.3 ("no valid compatibility manifest"), 0.84.0 and 0.84.1 ("was built with eve 0.85.0. Upgrade eve to 0.85.0 or later") and 0.85.0 (`eve build` clean). Any rebuild on a newer eve will raise the floor again to that eve. - **ai 7.0.155 (2026-10-28) applied everywhere except `packages/eve` + `examples/eve-demo` (stay on 7.0.154; `@ai-sdk/react ^4.0.159`); with 7.0.155 `packages/eve` tsc fails at `src/sandbox.ts:340` (spawn return type vs eve 0.82.0 overloads) and eve-demo `next build` fails; eve 0.84.1 / 0.85.0 NOT applied.** Same state as 0.84.0: extension typecheck on 0.85.0 still fails (`SessionContext` missing from `eve/tools`, hook key `message.received` gone, `events` rejected on tool defs) and published `@upstash/agentkit-eve-extension@0.16.1` still fails `eve build` on eve 0.85.0 ("uses an old epoch-based distribution manifest"); needs a source migration. Published `@upstash/agentkit-ai-sdk@0.15.1` typechecks (`skipLibCheck: false`, with `@types/json-schema` installed, which `@ai-sdk/provider` omits) and imports on ai 7.0.155. TanStack AI 1.0.0 still not applied. +- **ai 7.0.156 (2026-10-29) applied everywhere except `packages/eve` + `examples/eve-demo` (stay on 7.0.154; `@ai-sdk/react ^4.0.160`); eve 0.87.0 NOT applied to the extension.** With 7.0.156 `packages/eve` tsc still fails at `src/sandbox.ts:340`. An extension rebuilt on eve 0.87.0 stamps the same `{formatVersion: 3, contractMajor: 0}` manifest as 0.85.0 but `builtWithEve: 0.87.0`, and eve 0.85.0 / 0.86.0 refuse it ("was built with eve 0.87.0. Upgrade eve to 0.87.0 or later"), so adopting 0.87 means raising the peer floor to `>=0.87.0` for no contract change; the extension stays on `^0.85.0` (published `0.17.0`, built with 0.85.0, builds clean on eve 0.85–0.87). The extension typechecks, builds and passes its mocked-model eval on 0.87.0. Published `@upstash/agentkit-eve@0.16.1` `redisMemory()` fails `eve build` on eve >=0.84 ("provider.recall Unknown key compaction.completed"); `redisDocuments()` and `./sandbox` build clean on 0.87.0 with `--skip-sandbox-prewarm`. **TanStack AI 1.0.0 still not applied** (same test-only typecheck/runtime breaks as above; the published `@upstash/agentkit-tanstack-ai@0.2.0` typechecks and imports on 1.0.0, the memory contract passes, and the demo e2e passes). - **The 0.74.0 → 0.82.0 bump (2026-10-22) moves everything, with a source migration in `packages/eve`.** eve 0.75.0 renamed the sandbox provider handle hooks `onSessionStop`/`onSessionDelete` → `onSandboxStop`/`onSandboxDelete`, and eve 0.66.0 had removed `MutableNetworkSandboxSession` (network policy became provider-specific). `packages/eve/src/sandbox.ts` now declares its own `UpstashSandboxSession = SandboxSession & { setNetworkPolicy }` (exported; what `ctx.getSandbox(environment)` returns) and uses the new hook names, so the `eve` peer floor moved `>=0.65.0` → **`>=0.75.0`** (floor read off the 0.74.0 vs 0.75.0 tarballs: `onSessionStop` present vs absent in `shared/sandbox-provider.d.ts`; not verified by a full `tsc` against 0.75.0). `packages/eve` + `examples/eve-demo` are on eve ^0.82.0. The extension rebuild re-stamps **tool 78→96, dynamicTool 73→88, hook 39→48**; only 0.82.0 accepts them (0.75.0–0.81.1 do not), floor `>=0.74.0` → **`>=0.82.0`**. `upstashPersistence` gained an `activities` store (`ActivityStore`, one JSON doc per thread at `:activities:`, same TTL as messages) because `@tanstack/ai-persistence` ≥0.8.0's conformance suite requires it, so ai-persistence moves 0.7.2 → 0.10.1 and the peer floors become `@tanstack/ai` `>=0.65.0` (`ActivityRecord` type), `@tanstack/ai-persistence` `>=0.8.0` (`ActivityStore` type). ai 7.0.148, @tanstack/ai 0.71.0, -memory 0.2.16 and the demo packages are bumped. - **The 0.72.1 → 0.74.0 bump (2026-10-08) is EXTENSION-ONLY again** (same reason: `packages/eve` still imports the removed `MutableNetworkSandboxSession`, `TS2305` in `src/sandbox.ts`/`sandbox.test.ts` on 0.74.0; `packages/eve` + `examples/eve-demo` stay on 0.65.0). The rebuild re-stamps diff --git a/examples/ai-sdk-demo/package.json b/examples/ai-sdk-demo/package.json index 6a5c0a9..40d1791 100644 --- a/examples/ai-sdk-demo/package.json +++ b/examples/ai-sdk-demo/package.json @@ -9,11 +9,11 @@ }, "dependencies": { "@ai-sdk/openai": "^4.0.71", - "@ai-sdk/react": "^4.0.159", + "@ai-sdk/react": "^4.0.160", "@upstash/agentkit-ai-sdk": "workspace:*", "@upstash/agentkit-sdk": "workspace:*", "@upstash/redis": "^1.38.4", - "ai": "7.0.155", + "ai": "7.0.156", "dotenv": "^16.4.5", "next": "16.2.9", "react": "19.2.6", diff --git a/examples/eve-extension-demo/package.json b/examples/eve-extension-demo/package.json index b63c1f7..438305f 100644 --- a/examples/eve-extension-demo/package.json +++ b/examples/eve-extension-demo/package.json @@ -18,7 +18,7 @@ "@upstash/agentkit-eve-extension": "workspace:*", "@upstash/redis": "^1.38.4", "@vercel/connect": "2.5.6", - "ai": "7.0.155", + "ai": "7.0.156", "eve": "^0.85.0", "zod": "4.4.3" }, diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json index e004b71..7e53acc 100644 --- a/packages/ai-sdk/package.json +++ b/packages/ai-sdk/package.json @@ -53,7 +53,7 @@ "@ai-sdk/openai": "^4.0.71", "@ai-sdk/provider": "^4.0.17", "@upstash/redis": "^1.38.4", - "ai": "7.0.155", + "ai": "7.0.156", "dotenv": "^16.4.5" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf75bb9..7f64158 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,8 +48,8 @@ importers: specifier: ^4.0.71 version: 4.0.71(zod@4.4.3) '@ai-sdk/react': - specifier: ^4.0.159 - version: 4.0.159(react@19.2.6)(zod@4.4.3) + specifier: ^4.0.160 + version: 4.0.160(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.4 version: 1.38.4 ai: - specifier: 7.0.155 - version: 7.0.155(zod@4.4.3) + specifier: 7.0.156 + version: 7.0.156(zod@4.4.3) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -216,13 +216,13 @@ importers: version: 1.38.4 '@vercel/connect': specifier: 2.5.6 - version: 2.5.6(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.155(zod@4.4.3))(eve@0.85.0) + version: 2.5.6(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.156(zod@4.4.3))(eve@0.85.0) ai: - specifier: 7.0.155 - version: 7.0.155(zod@4.4.3) + specifier: 7.0.156 + version: 7.0.156(zod@4.4.3) eve: specifier: ^0.85.0 - version: 0.85.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.155(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) + version: 0.85.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.156(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) zod: specifier: 4.4.3 version: 4.4.3 @@ -354,8 +354,8 @@ importers: specifier: ^4.0.17 version: 4.0.17 ai: - specifier: 7.0.155 - version: 7.0.155(zod@4.4.3) + specifier: 7.0.156 + version: 7.0.156(zod@4.4.3) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -405,7 +405,7 @@ importers: version: 24.13.2 eve: specifier: ^0.85.0 - version: 0.85.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.155(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) + version: 0.85.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.156(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) typescript: specifier: 7.0.2 version: 7.0.2 @@ -497,8 +497,8 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@4.0.128': - resolution: {integrity: sha512-vAEUQPql1k+wyU3JUEosUupdwnwFD7Sts05EcxAYifY4Z1+dl+rD88y4gz8QZOnOidxzYZe2gPSBsWlo/ZahOw==} + '@ai-sdk/gateway@4.0.129': + resolution: {integrity: sha512-8JvaQBaIfM8jo54kg+picQpY9qBzP/hG+YIXJO9KDL+9RpdoTjzp0/mH7XjfaqNMHAf85dM2/tCBDOOyBN7Bgg==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -541,8 +541,8 @@ packages: resolution: {integrity: sha512-Ip7ZQBq/FiGpM1mmOVseTtX9PBjnzo0bLC41WTgBSBBRUBz6LhNFGbYaNUzI6hPm6ll1oNSPOAZc/q/z+3QZEQ==} engines: {node: '>=22'} - '@ai-sdk/react@4.0.159': - resolution: {integrity: sha512-dk+4cq+ahLexOBJZtw0uR2qltA3bAN9pxmU3yPEnvcRO4bW5C56K1/2YIfobvClZChmKJJEDNAokvBGDQ3pxYQ==} + '@ai-sdk/react@4.0.160': + resolution: {integrity: sha512-SgvmRHKWbb2Wqs4zAPYqLAmiZTxpKItxG/EycbOkPr6KUxwzJemgaQ30RdRQBbJ5zerHh8c2T2cqm9eg8hl78A==} engines: {node: '>=22'} peerDependencies: react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 @@ -3184,8 +3184,8 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - ai@7.0.155: - resolution: {integrity: sha512-df/UwKgOix8Vc+dj684bQlHCClNojrG5T7Jr5aJvFyz+PlBo7gEgMALPzP7LdeD12Gxc6i0pzpboEszDh9V1wA==} + ai@7.0.156: + resolution: {integrity: sha512-qtXze+Go5L2K4nOhrrg75ETZN++FVB2dWipm0bGo1bCh4JtMZ7xD6zSAZNSv/94e3Ks4LUZ5IWoBOIa+KHiIng==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -5434,7 +5434,7 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 4.4.3 - '@ai-sdk/gateway@4.0.128(zod@4.4.3)': + '@ai-sdk/gateway@4.0.129(zod@4.4.3)': dependencies: '@ai-sdk/provider': 4.0.34 '@ai-sdk/provider-utils': 5.0.70(zod@4.4.3) @@ -5490,12 +5490,12 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/react@4.0.159(react@19.2.6)(zod@4.4.3)': + '@ai-sdk/react@4.0.160(react@19.2.6)(zod@4.4.3)': dependencies: '@ai-sdk/mcp': 2.0.86(zod@4.4.3) '@ai-sdk/provider': 4.0.34 '@ai-sdk/provider-utils': 5.0.70(zod@4.4.3) - ai: 7.0.155(zod@4.4.3) + ai: 7.0.156(zod@4.4.3) react: 19.2.6 swr: 2.4.1(react@19.2.6) throttleit: 2.1.0 @@ -7795,13 +7795,13 @@ snapshots: ai: 7.0.154(zod@4.4.3) eve: 0.82.0(@opentelemetry/api@1.9.1)(ai@7.0.154(zod@4.4.3)) - '@vercel/connect@2.5.6(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.155(zod@4.4.3))(eve@0.85.0)': + '@vercel/connect@2.5.6(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.156(zod@4.4.3))(eve@0.85.0)': dependencies: '@vercel/oidc': 4.0.4 optionalDependencies: '@ai-sdk/mcp': 2.0.86(zod@4.4.3) - ai: 7.0.155(zod@4.4.3) - eve: 0.85.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.155(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) + ai: 7.0.156(zod@4.4.3) + eve: 0.85.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.156(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) '@vercel/oidc@3.2.0': {} @@ -7872,9 +7872,9 @@ snapshots: '@ai-sdk/provider-utils': 5.0.69(zod@4.4.3) zod: 4.4.3 - ai@7.0.155(zod@4.4.3): + ai@7.0.156(zod@4.4.3): dependencies: - '@ai-sdk/gateway': 4.0.128(zod@4.4.3) + '@ai-sdk/gateway': 4.0.129(zod@4.4.3) '@ai-sdk/provider': 4.0.34 '@ai-sdk/provider-utils': 5.0.70(zod@4.4.3) zod: 4.4.3 @@ -8435,14 +8435,14 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 - eve@0.85.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.155(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)): + eve@0.85.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.156(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)): dependencies: - ai: 7.0.155(zod@4.4.3) + ai: 7.0.156(zod@4.4.3) nitro: 3.0.260903-beta undici: 8.10.2 optionalDependencies: '@opentelemetry/api': 1.9.1 - '@vercel/connect': 2.5.6(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.155(zod@4.4.3))(eve@0.85.0) + '@vercel/connect': 2.5.6(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.156(zod@4.4.3))(eve@0.85.0) next: 16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 vite: 5.4.21(@types/node@20.19.43)(lightningcss@1.32.0) From 30d6da570bf722737cdf3339497db0844d2cc373 Mon Sep 17 00:00:00 2001 From: Upstash Box Date: Thu, 29 Oct 2026 09:29:15 +0000 Subject: [PATCH 2/3] fix(eve): migrate to eve 0.87 (redisMemory, UpstashSandbox), peer >=0.87.0 - redisMemory() no longer registers the removed compaction.completed recall hook, which made `eve build` reject the slot ("provider.recall Unknown key compaction.completed"). save_memory takes its ordering sequence from the turn.started recall (eve's memory tools context has no `turn`). - UpstashSandbox implements eve's connection-based provider contract: prepare via ctx.populate, byte-level connection, idempotent start keyed on the session id, SandboxSessionGoneError on resume, onSessionEnd. - packages/eve and examples/eve-demo move to eve ^0.87.0 / ai 7.0.156 (demo: turn.reply, new useEveAgent shape, @vercel/connect 2.5.6, next 16.2.9 so one eve instance is resolved). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CMK4etZKNEc4Rzig1SCPpJ --- .changeset/eve-eve-087.md | 5 + CLAUDE.md | 2 +- README.md | 2 +- docs/memory-redesign.md | 4 + .../eve-demo/app/_components/agent-chat.tsx | 20 +- examples/eve-demo/evals/memory.eval.ts | 6 +- examples/eve-demo/package.json | 8 +- examples/mcp-toolkit-demo/next-env.d.ts | 2 +- packages/eve/README.md | 22 +- packages/eve/package.json | 6 +- packages/eve/src/memory/index.ts | 13 +- packages/eve/src/memory/memory.test.ts | 100 ++--- packages/eve/src/memory/provider.ts | 33 +- packages/eve/src/sandbox.test.ts | 405 +++++++++++++----- packages/eve/src/sandbox.ts | 354 +++++++-------- pnpm-lock.yaml | 300 +++---------- 16 files changed, 609 insertions(+), 673 deletions(-) create mode 100644 .changeset/eve-eve-087.md diff --git a/.changeset/eve-eve-087.md b/.changeset/eve-eve-087.md new file mode 100644 index 0000000..8c0d9b7 --- /dev/null +++ b/.changeset/eve-eve-087.md @@ -0,0 +1,5 @@ +--- +"@upstash/agentkit-eve": minor +--- + +Migrate to eve 0.87 and raise the `eve` peer floor to `>=0.87.0`. `redisMemory()` no longer registers the `compaction.completed` recall hook that eve 0.84 removed, which made `eve build` reject the whole slot ("provider.recall Unknown key compaction.completed"); `save_memory` now takes its ordering sequence from the turn that recalled, since eve's memory tools context no longer carries `turn`. `UpstashSandbox` implements eve's connection-based sandbox provider contract (`prepare` via `ctx.populate`, a byte-level `connection`, `onSessionEnd`): `start` is now an idempotent upsert keyed on the session id, `resume` throws eve's `SandboxSessionGoneError` when the box is gone, and a writable `stdin` is rejected. Apps on eve 0.84–0.86 should upgrade eve (their `./sandbox` and `./memory` entries were already broken on those versions). diff --git a/CLAUDE.md b/CLAUDE.md index c16df99..fe6b1f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1202,7 +1202,7 @@ implements TanStack AI's own backend contracts (see its section below) — keep - **eve 0.83.3 + ai 7.0.154 (2026-10-27): extension + `examples/eve-extension-demo` move to eve `^0.83.3`, ai is `7.0.154` everywhere (`@ai-sdk/react ^4.0.158`).** Same contracts as 0.83.0, peer floor unchanged. **eve 0.84.0 (2026-10-26) is NOT applied: it breaks `packages/eve-extension` and `packages/eve` sources** (needs a source migration). Published `@upstash/agentkit-eve-extension@0.16.1` fails `eve build` on eve 0.84.0 with "uses an old epoch-based distribution manifest … Rebuild the extension with this eve release". Extension typecheck on 0.84.0: `SessionContext` no longer exported from `eve/tools`; hook key `message.received` gone; tool defs no longer accept `events`. `packages/eve` on 0.84.0: `signal` removed from `MemoryDocumentReadInput`/`WriteInput`, `MemoryCompactionCompletedContext` and the `compaction.completed` recall hook gone, `MemoryToolsContext.turn` gone, plus the sandbox typings above. TanStack AI 1.0.0 still not applied (see above); ai 7.0.154 is clean (published `@upstash/agentkit-ai-sdk@0.15.1` typechecks and imports on it). - **eve 0.85.0: the extension IS migrated (extension-only; `packages/eve` + `examples/eve-demo` stay on eve 0.82.0).** eve 0.84.0 replaced per-capability contract epochs with a `{formatVersion: 3, builtWithEve, contractMajor: 0}` manifest and a v27 session-event/dynamic API. Source changes in `packages/eve-extension`: `SessionContext` is gone from `eve/tools` (own `SessionRef = Pick` in `extension/lib/session.ts`, used for the `userId` function and `resolveUserId`); dynamic tools use `defineDynamic(ctx => tool | null)` instead of `defineDynamic({events: {"session.started"}})`; the chat-history hook maps `message.received` -> `delivery.consumed` (text parts of `event.data.parts`) and `message.completed` -> `content.completed` filtered to `kind: "text"` + `phase: "reply"` (one stored assistant message per reply text part; narration is not stored). **The peer floor is `>=0.85.0`**: verified by packing the dist (built with 0.85.0) into consumers on 0.83.3 ("no valid compatibility manifest"), 0.84.0 and 0.84.1 ("was built with eve 0.85.0. Upgrade eve to 0.85.0 or later") and 0.85.0 (`eve build` clean). Any rebuild on a newer eve will raise the floor again to that eve. - **ai 7.0.155 (2026-10-28) applied everywhere except `packages/eve` + `examples/eve-demo` (stay on 7.0.154; `@ai-sdk/react ^4.0.159`); with 7.0.155 `packages/eve` tsc fails at `src/sandbox.ts:340` (spawn return type vs eve 0.82.0 overloads) and eve-demo `next build` fails; eve 0.84.1 / 0.85.0 NOT applied.** Same state as 0.84.0: extension typecheck on 0.85.0 still fails (`SessionContext` missing from `eve/tools`, hook key `message.received` gone, `events` rejected on tool defs) and published `@upstash/agentkit-eve-extension@0.16.1` still fails `eve build` on eve 0.85.0 ("uses an old epoch-based distribution manifest"); needs a source migration. Published `@upstash/agentkit-ai-sdk@0.15.1` typechecks (`skipLibCheck: false`, with `@types/json-schema` installed, which `@ai-sdk/provider` omits) and imports on ai 7.0.155. TanStack AI 1.0.0 still not applied. -- **ai 7.0.156 (2026-10-29) applied everywhere except `packages/eve` + `examples/eve-demo` (stay on 7.0.154; `@ai-sdk/react ^4.0.160`); eve 0.87.0 NOT applied to the extension.** With 7.0.156 `packages/eve` tsc still fails at `src/sandbox.ts:340`. An extension rebuilt on eve 0.87.0 stamps the same `{formatVersion: 3, contractMajor: 0}` manifest as 0.85.0 but `builtWithEve: 0.87.0`, and eve 0.85.0 / 0.86.0 refuse it ("was built with eve 0.87.0. Upgrade eve to 0.87.0 or later"), so adopting 0.87 means raising the peer floor to `>=0.87.0` for no contract change; the extension stays on `^0.85.0` (published `0.17.0`, built with 0.85.0, builds clean on eve 0.85–0.87). The extension typechecks, builds and passes its mocked-model eval on 0.87.0. Published `@upstash/agentkit-eve@0.16.1` `redisMemory()` fails `eve build` on eve >=0.84 ("provider.recall Unknown key compaction.completed"); `redisDocuments()` and `./sandbox` build clean on 0.87.0 with `--skip-sandbox-prewarm`. **TanStack AI 1.0.0 still not applied** (same test-only typecheck/runtime breaks as above; the published `@upstash/agentkit-tanstack-ai@0.2.0` typechecks and imports on 1.0.0, the memory contract passes, and the demo e2e passes). +- **eve 0.87.0 + ai 7.0.156 (2026-10-29): `packages/eve` + `examples/eve-demo` are migrated and back in step with the rest of the repo on eve `^0.87.0` / ai 7.0.156 (the eve 0.82.0 hold above is over); the extension stays on eve `^0.85.0`.** Source changes in `packages/eve`: `redisMemory()` registers only `turn.started` recall (eve 0.84 removed `compaction.completed`; registering it made `eve build` fail with "provider.recall Unknown key compaction.completed"), and because eve 0.84+ gives a memory *tool* no `turn`, `turn.started` stores the turn sequence at `:sequence::` for `save_memory` to order its record by (`read_session` sorts on it; without a prior recall it falls back to 0). `UpstashSandbox` follows the connection-based provider contract: `prepare` returns `{snapshotId: null}` when `ctx.isEmpty`, otherwise creates a temp box, `ctx.populate(connection)` (eve strips `prepare` from the options and runs it there), snapshots (named from `ctx.templateKey`) and deletes the box; `start` is an idempotent upsert on the box name `eve-` (suffix fallback if a deleted record holds the name); `resume` throws `SandboxSessionGoneError`; `onSessionEnd` deletes by recorded `boxId`, or by the derived name when state is null, and is a no-op when nothing exists; `connection.stop()` is Box `pause`; `setNetworkPolicy` is a connection extension; a writable `stdin` spawn throws. **The peer floor is `>=0.87.0`**: the source typechecks with 0 errors only on 0.87.0 (0.83.x fails on `signal`/`stdin`, 0.84.0–0.86.0 on `signal` in sandbox and `MemoryDocumentReadInput`/`WriteInput`, 0.82.0 and older on the old provider handle API). `examples/eve-demo` also needed: `turn.reply` instead of `turn.message` in `evals/memory.eval.ts`; `useEveAgent` now returns `messages`/`status: idle|busy|resuming` with `tool` parts (was `data.messages`, `dynamic-tool`); `@vercel/connect` 2.5.6 (eve 0.87 peer) and `next` 16.2.9 so `packages/eve` and the demo resolve the **same** eve instance (two peer variants of eve make `MemoryProvider` types nominally incompatible). **Not verified live:** the `UpstashSandbox` live-Box suite (needs `UPSTASH_BOX_API_KEY`) was not run; the offline suite and the eve-demo mocked eval (which reaches `Box.create` from `prepare`) were. The extension was not moved to eve 0.87: a dist built on 0.87.0 is refused by eve 0.85/0.86, so adopting it forces its peer floor to `>=0.87.0` for no contract change. TanStack AI 1.0.0 still not applied (test-only typecheck/runtime breaks; the published `@upstash/agentkit-tanstack-ai@0.2.0` typechecks and imports on 1.0.0, the memory contract passes, and the demo e2e passes). - **The 0.74.0 → 0.82.0 bump (2026-10-22) moves everything, with a source migration in `packages/eve`.** eve 0.75.0 renamed the sandbox provider handle hooks `onSessionStop`/`onSessionDelete` → `onSandboxStop`/`onSandboxDelete`, and eve 0.66.0 had removed `MutableNetworkSandboxSession` (network policy became provider-specific). `packages/eve/src/sandbox.ts` now declares its own `UpstashSandboxSession = SandboxSession & { setNetworkPolicy }` (exported; what `ctx.getSandbox(environment)` returns) and uses the new hook names, so the `eve` peer floor moved `>=0.65.0` → **`>=0.75.0`** (floor read off the 0.74.0 vs 0.75.0 tarballs: `onSessionStop` present vs absent in `shared/sandbox-provider.d.ts`; not verified by a full `tsc` against 0.75.0). `packages/eve` + `examples/eve-demo` are on eve ^0.82.0. The extension rebuild re-stamps **tool 78→96, dynamicTool 73→88, hook 39→48**; only 0.82.0 accepts them (0.75.0–0.81.1 do not), floor `>=0.74.0` → **`>=0.82.0`**. `upstashPersistence` gained an `activities` store (`ActivityStore`, one JSON doc per thread at `:activities:`, same TTL as messages) because `@tanstack/ai-persistence` ≥0.8.0's conformance suite requires it, so ai-persistence moves 0.7.2 → 0.10.1 and the peer floors become `@tanstack/ai` `>=0.65.0` (`ActivityRecord` type), `@tanstack/ai-persistence` `>=0.8.0` (`ActivityStore` type). ai 7.0.148, @tanstack/ai 0.71.0, -memory 0.2.16 and the demo packages are bumped. - **The 0.72.1 → 0.74.0 bump (2026-10-08) is EXTENSION-ONLY again** (same reason: `packages/eve` still imports the removed `MutableNetworkSandboxSession`, `TS2305` in `src/sandbox.ts`/`sandbox.test.ts` on 0.74.0; `packages/eve` + `examples/eve-demo` stay on 0.65.0). The rebuild re-stamps diff --git a/README.md b/README.md index 766cf62..c548b35 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ are powered by [Upstash Redis Search](https://upstash.com/docs/redis/search/intr documents (so they work off Vercel), and `redisMemory()` is a full provider with ranked recall and automatic capture. - **Code sandbox** (Eve only) — an [Upstash Box](https://github.com/upstash/box) sandbox provider for - Eve's `defineSandbox` (`UpstashSandbox`, eve ≥ 0.65). + Eve's `defineSandbox` (`UpstashSandbox`, eve ≥ 0.87). - **Tool-call cache** — memoize deterministic tool results keyed by arguments. - **Durable MCP tasks and events** (`@upstash/mcp-toolkit`) — a long-running MCP tool returns a task instead of blocking: a native MCP task the client polls (Claude Code), or a task id the model diff --git a/docs/memory-redesign.md b/docs/memory-redesign.md index 69fe47d..e72c87e 100644 --- a/docs/memory-redesign.md +++ b/docs/memory-redesign.md @@ -98,6 +98,10 @@ knows its own. Neither needs to know how many records the other wrote. ## Lifecycle +> **Update (eve 0.84):** eve removed the `compaction.requested` / `compaction.completed` hooks, so the +> provider now registers only `turn.started` (recall) and `turn.completed` (capture). The rows below +> describe the original design. + | eve phase | what happens | | --- | --- | | `turn.started` | recall — one `$smart` query filtered to `source:agent, deleted:false`, plus a `count` of this scope's non-agent records | diff --git a/examples/eve-demo/app/_components/agent-chat.tsx b/examples/eve-demo/app/_components/agent-chat.tsx index c9636d9..0e93a05 100644 --- a/examples/eve-demo/app/_components/agent-chat.tsx +++ b/examples/eve-demo/app/_components/agent-chat.tsx @@ -23,7 +23,7 @@ export function AgentChat({ // Send the selected user as a header so the agent scopes memory + cached tools to this user. The // parent remounts this component (via a `key`) when the user changes, so a fresh session starts. const agent = useEveAgent({ headers: { "x-user-id": userId } }); - const busy = agent.status === "submitted" || agent.status === "streaming"; + const busy = agent.status !== "idle"; function send(text: string) { const value = text.trim(); @@ -66,31 +66,31 @@ export function AgentChat({ ) : null}
- {agent.data.messages.length === 0 && ( + {agent.messages.length === 0 && (

- An eve agent backed by @upstash/agentkit-eve. It has memory tools - (recall_memory / save_memory), schema-driven Redis Search tools - over a books index (search_books / aggregate_books /{" "} + An eve agent backed by @upstash/agentkit-eve. It has memory tools ( + recall_memory / save_memory), schema-driven Redis Search + tools over a books index (search_books / aggregate_books /{" "} count_books), and a cached get_weather tool; requests are rate-limited at the channel.

)} - {agent.data.messages.map((m) => { + {agent.messages.map((m) => { const text = m.parts.map((part) => (part.type === "text" ? part.text : "")).join(""); return (
{m.role === "user" ? "You" : "Assistant"}
{/* Show every tool the agent called, with its state + result. */} {m.parts.map((part, i) => - part.type === "dynamic-tool" ? ( + part.type === "tool" ? (
- 🔧 {part.toolName} + 🔧 {part.name} · {part.state} {part.output != null ? (
{JSON.stringify(part.output, null, 2)}
) : null} - {part.errorText ? ( -
{part.errorText}
+ {part.error ? ( +
{part.error.message}
) : null}
) : null, diff --git a/examples/eve-demo/evals/memory.eval.ts b/examples/eve-demo/evals/memory.eval.ts index c91f64e..7e9c518 100644 --- a/examples/eve-demo/evals/memory.eval.ts +++ b/examples/eve-demo/evals/memory.eval.ts @@ -70,7 +70,7 @@ export default defineEval({ let recalled = ""; for (let attempt = 0; attempt < 4; attempt += 1) { const turn = await session.send("What colour do I like?"); - recalled = turn.message ?? ""; + recalled = turn.reply ?? ""; if (recalled.includes(NONCE)) break; await new Promise((resolve) => setTimeout(resolve, 1_000)); } @@ -87,7 +87,7 @@ export default defineEval({ // 5. The saved document comes back in the next turn's recalled context. const recap = await session.send("Anything else you know?"); - t.check(recap.message, includes("Persistent memories for profile")); - t.check(recap.message, includes("deploy target is Vercel")); + t.check(recap.reply, includes("Persistent memories for profile")); + t.check(recap.reply, includes("deploy target is Vercel")); }, }); diff --git a/examples/eve-demo/package.json b/examples/eve-demo/package.json index 3e00a53..80ea891 100644 --- a/examples/eve-demo/package.json +++ b/examples/eve-demo/package.json @@ -27,16 +27,16 @@ "@upstash/agentkit-eve": "workspace:*", "@upstash/box": "^0.7.5", "@upstash/redis": "^1.38.4", - "@vercel/connect": "0.2.2", - "ai": "7.0.154", + "@vercel/connect": "2.5.6", + "ai": "7.0.156", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", - "eve": "^0.82.0", + "eve": "^0.87.0", "lucide-react": "1.16.0", "motion": "12.40.0", "nanoid": "5.1.11", - "next": "16.2.6", + "next": "16.2.9", "radix-ui": "1.4.3", "react": "19.2.6", "react-dom": "19.2.6", diff --git a/examples/mcp-toolkit-demo/next-env.d.ts b/examples/mcp-toolkit-demo/next-env.d.ts index c4b7818..9edff1c 100644 --- a/examples/mcp-toolkit-demo/next-env.d.ts +++ b/examples/mcp-toolkit-demo/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/eve/README.md b/packages/eve/README.md index d333aa9..ed1291a 100644 --- a/packages/eve/README.md +++ b/packages/eve/README.md @@ -123,23 +123,21 @@ Neither replaces the [memory tools](#memory-tools) above: those need no memory s version, and stay the right choice for purely model-driven memory.
-When each hook runs (the four lifecycle points) +When each hook runs (the two lifecycle points) -eve drives a memory slot at four points. Both integrations recall at the same two; only +eve 0.84+ drives a memory provider at two points. Both integrations recall at `turn.started`; only `redisMemory()` writes. | eve phase | `fileMemory({ backend: redisDocuments() })` | `redisMemory()` | | --- | --- | --- | | `turn.started` | read the document, inject it whole | BM25 `$smart` recall for the turn's user text → one keyed message, injected **before** the model runs | | `turn.completed` | — | write this turn's messages (`rememberMessages`), then wait for indexing | -| `compaction.requested` | — | nothing — messages are stored as they happen, so the summarizer takes nothing with it | -| `compaction.completed` | read and inject against the new checkpoint | recall again against the new checkpoint | -Two consequences worth knowing. Capture runs **after** the response is delivered, which is why +One consequence worth knowing: capture runs **after** the response is delivered, which is why blocking on Redis Search's `waitIndexing()` there costs the caller nothing and makes what you just -said recallable on the very next turn. And recall runs a second time at `compaction.completed` so -memory is re-injected against the fresh checkpoint rather than being folded into the summary — eve -excludes recalled records from the summarizer for the same reason. +said recallable on the very next turn. (Older eve versions also had `compaction.requested` / +`compaction.completed` hooks; eve 0.84 removed them, and with every turn's messages stored as they +happen there is nothing to rescue before the summarizer runs.) Recall is also cached per eve `operationId` (1h). eve requires providers to treat that id as an idempotency key — *"replaying a recall with a different result is an error"* — and a live ranked @@ -229,9 +227,9 @@ conversation changes topic. `scope.key` that is used as the storage partition. Derive it from verified session auth, never from model input — `byPrincipal` from `eve/memory/scope` is the built-in shorthand. -**Requires eve ≥ 0.45.2** (`eve/memory` landed in 0.45.1, `eve/memory/file` in 0.45.2). The package -as a whole requires **eve ≥ 0.65** (its `eve` peer range), because the `./sandbox` provider targets the -eve 0.64+ provider API. +**Requires eve ≥ 0.87.0** (the package's `eve` peer range): the memory providers and the `./sandbox` +provider target eve 0.87's APIs (`ctx.populate`, `onSessionEnd`, `signal` on sandbox and memory +inputs), and the typings only match from that release.
@@ -344,7 +342,7 @@ export const environment = UpstashSandbox.environment({ export default defineSandbox(() => environment.open()); ``` -Requires **eve ≥ 0.65** and **`@upstash/box` ≥ 0.7.1** (an optional peer, only needed when you import +Requires **eve ≥ 0.87.0** and **`@upstash/box` ≥ 0.7.1** (an optional peer, only needed when you import `@upstash/agentkit-eve/sandbox`). The provider reads `UPSTASH_BOX_API_KEY` when eve prepares the environment (at `eve build`) and at run time. diff --git a/packages/eve/package.json b/packages/eve/package.json index 3b17921..e996629 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -60,14 +60,14 @@ "devDependencies": { "@upstash/box": "^0.7.5", "@upstash/redis": "^1.38.4", - "ai": "7.0.154", + "ai": "7.0.156", "dotenv": "^16.4.5", - "eve": "^0.82.0" + "eve": "^0.87.0" }, "peerDependencies": { "@upstash/box": ">=0.7.1", "@upstash/redis": ">=1.38.4", - "eve": ">=0.75.0" + "eve": ">=0.87.0" }, "peerDependenciesMeta": { "@upstash/box": { diff --git a/packages/eve/src/memory/index.ts b/packages/eve/src/memory/index.ts index 83982ee..14bffb8 100644 --- a/packages/eve/src/memory/index.ts +++ b/packages/eve/src/memory/index.ts @@ -23,21 +23,18 @@ * * ## Lifecycle * - * eve drives a slot at four points. Both integrations recall at the same two; only - * {@link redisMemory} writes, and it writes at `turn.completed` only — capture needs the turn's own - * input, which `compaction.requested` does not carry (`turn` is nullable there). + * {@link redisMemory} hooks the two points eve 0.84+ gives a memory provider: it recalls at + * `turn.started` and writes at `turn.completed` (capture needs the turn's own input). eve no longer + * has a `compaction.completed` recall hook, so recall is not repeated after compaction. * * | phase | `fileMemory({ backend: redisDocuments() })` | {@link redisMemory} | * | --- | --- | --- | * | `turn.started` | read the document, inject it whole | ranked recall → one keyed message, before the model runs | * | `turn.completed` | — | write captures (`rememberMessages`), wait for indexing | - * | `compaction.requested` | — | — | - * | `compaction.completed` | read and inject against the new checkpoint | recall again against the new checkpoint | * * Capture runs *after* the response is delivered, which is what makes the `waitIndexing()` there - * free. Recall runs a second time at `compaction.completed` so memory is re-injected against the - * fresh checkpoint instead of being folded into the summary, and is cached per eve `operationId` - * because eve treats that id as an idempotency key and rejects a replay that differs. + * free. Recall is cached per eve `operationId` because eve treats that id as an idempotency key and + * rejects a replay that differs. * * Neither replaces `defineMemoryRecallTool`/`defineMemorySaveTool` from the package root. Those are * plain eve tools you drop into `agent/tools/*.ts` — they work on any eve version, need no memory diff --git a/packages/eve/src/memory/memory.test.ts b/packages/eve/src/memory/memory.test.ts index 7a1a126..71b0dce 100644 --- a/packages/eve/src/memory/memory.test.ts +++ b/packages/eve/src/memory/memory.test.ts @@ -43,6 +43,7 @@ function operationContext(options: { input?: unknown[]; messages?: unknown[]; sessionId?: string; + sequence?: number; }) { return { abortSignal: signal, @@ -58,22 +59,20 @@ function operationContext(options: { }, messages: options.messages ?? [], operationId: options.operationId ?? `op-${Math.random().toString(36).slice(2)}`, - turn: { id: "turn-1", input: options.input ?? [], sequence: 1 }, + turn: { id: "turn-1", input: options.input ?? [], sequence: options.sequence ?? 1 }, }; } type Recall = NonNullable; type Capture = NonNullable["turn.completed"]>; -/** The two lifecycle points eve can ask a provider to recall at. */ -type RecallHook = "turn.started" | "compaction.completed"; -/** The two lifecycle points eve can ask a provider to capture at. */ -type CaptureHook = "turn.completed" | "compaction.requested"; +/** The lifecycle point eve 0.84+ asks a provider to recall at (`compaction.completed` is gone). */ +type RecallHook = "turn.started"; +/** The lifecycle point this provider captures at (`compaction.requested` is not registered). */ +type CaptureHook = "turn.completed"; /** - * Run a provider's recall at `hook` and return the single keyed message's content. Both hooks go - * through here so `compaction.completed` — the one eve only reaches after a compaction checkpoint, - * and so the easiest to leave wired-but-broken — is exercised exactly like `turn.started`. + * Run a provider's recall at `hook` and return the single keyed message's content. */ async function recallAt( provider: MemoryProvider, @@ -212,10 +211,12 @@ describe("eve memory integration (offline)", () => { it("redisMemory() implements eve's MemoryProvider surface", () => { const provider = redisMemory({ redis: offlineRedis }); - // eve requires `recall["turn.started"]`; the other three handlers are optional but we register - // all of them, which is what makes recall and capture automatic. + // eve requires `recall["turn.started"]`; `turn.completed` capture is optional but registered, + // which is what makes recall and capture automatic. expect(typeof provider.recall["turn.started"]).toBe("function"); - expect(typeof provider.recall["compaction.completed"]).toBe("function"); + // eve 0.84 removed the `compaction.completed` recall hook; registering it would make eve reject + // the whole slot ("provider.recall Unknown key"). + expect(Object.keys(provider.recall)).toEqual(["turn.started"]); expect(typeof provider.capture?.["turn.completed"]).toBe("function"); // No `compaction.requested` — messages are stored as they happen, so nothing is lost to the // summarizer, and it was the only context where the ordering `sequence` could be null. @@ -286,7 +287,7 @@ describe("eve memory integration (offline)", () => { // // The live suite below proves the round trip end to end, but it cannot prove *which* calls // happened: a provider that recalled from an in-process cache, queried the wrong index, or never -// wired `compaction.completed` at all could still satisfy it. These do that part deterministically +// wired a removed hook could still satisfy it. These do that part deterministically // — no network, no BM25, no indexing lag. // ------------------------------------------------------------------------------------------- @@ -327,32 +328,32 @@ describe("redisMemory() — recall and capture invocation (offline)", () => { }); }); - it("recall['compaction.completed'] runs the same recall against the same locked scope", async () => { - const recall = vi.spyOn(AgentMemory.prototype, "recall").mockResolvedValue([]); - const provider = redisMemory({ - redis: scriptedRedis().redis, - topK: 3, - minScore: 0.25, - replayCacheTtlSeconds: 0, - }); + // eve 0.84+ hands a memory *tool* no `turn`, so `save_memory` cannot read the sequence its record + // is ordered by (`read_session` sorts on it). `turn.started` leaves it behind for the tool. + it("save_memory orders its record by the sequence of the turn that recalled", async () => { + vi.spyOn(AgentMemory.prototype, "recall").mockResolvedValue([]); + const add = vi.spyOn(AgentMemory.prototype, "add").mockResolvedValue({ id: "fact1" } as never); + const provider = redisMemory({ redis: scriptedRedis().redis, waitForIndexing: false }); - // eve only reaches this hook after a compaction checkpoint, so nothing else in the suite would - // notice if it were registered but broken. - const content = await recallAt( + await recallAt( provider, - "compaction.completed", - operationContext({ scopeKey: SCOPE, input: [userMessage("what theme do I like?")] }), + "turn.started", + operationContext({ scopeKey: SCOPE, sessionId: "s-seq", sequence: 7 }), ); - expect(recall).toHaveBeenCalledTimes(1); - expect(recall).toHaveBeenCalledWith({ - userId: USER_ID, - topK: 3, - query: "what theme do I like?", - minScore: 0.25, - filter: { source: { $eq: "agent" }, deleted: { $eq: false } }, + // The tools context is exactly eve's `MemoryToolsContext`: no `turn`. + const { turn: _turn, ...toolsContext } = operationContext({ + scopeKey: SCOPE, + sessionId: "s-seq", }); - expect(content).toContain("# Recalled memories for recall"); + const tools = await provider.tools!(toolsContext as never); + await callTool(tools, "save_memory", { text: "The user deploys on Fridays" }); + + expect(add).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ sessionId: "s-seq", source: "agent", sequence: 7 }), + }), + ); }); it("recall reaches Redis as a userId-scoped $smart query on the shared agentkit:memory index", async () => { @@ -1191,30 +1192,6 @@ describe.skipIf(!hasRedisCreds)("redisMemory() — MemoryProvider (live Redis)", expect(content).toContain(`${id}: ${stored.text}`); }); - it("recalls again at compaction.completed, against the same locked scope", async () => { - const scope = newScope("compaction"); - const tools = await provider.tools!( - operationContext({ scopeKey: scope, slot: "recall" }) as never, - ); - await callTool(tools, "save_memory", { text: "The user's deploy target is Vercel" }); - await index.waitIndexing(); - - // There is no `compaction.requested` capture any more — messages are stored as they happen, so - // nothing is left to rescue before the summarizer runs. - expect(provider.capture?.["compaction.requested"]).toBeUndefined(); - - const content = await pollUntil( - () => - recallAt( - provider, - "compaction.completed", - operationContext({ scopeKey: scope, input: [userMessage("what is the deploy target?")] }), - ), - (c) => c.includes("Vercel"), - ); - expect(content).toContain("Vercel"); - }); - it("rejects a model-supplied memory id that could address another scope's key", async () => { const tools = await provider.tools!(operationContext({ scopeKey }) as never); await expect(callTool(tools, "forget_memory", { id: "../../other:key" })).rejects.toThrow( @@ -1269,10 +1246,11 @@ describe.skipIf(!hasRedisCreds)("redisMemory() — MemoryProvider (live Redis)", ], }); const both = redisMemory({ redis, rememberMessages: "all" }); - const tools = await both.tools!({ - ...context, - turn: { id: "t", input: [], sequence: 1 }, - } as never); + // eve always recalls at `turn.started` before the model can call a tool, and that is where the + // turn's sequence reaches `save_memory` (a memory tool's context has no `turn` in eve 0.84+). + await recallAt(both, "turn.started", context); + const { turn: _turn, ...toolsContext } = context; + const tools = await both.tools!(toolsContext as never); // A curated fact saved mid-turn, then both halves of the turn captured at turn end — // `"all"` is needed for the assistant's reply, which the default no longer stores. diff --git a/packages/eve/src/memory/provider.ts b/packages/eve/src/memory/provider.ts index 8c4317c..0b3302b 100644 --- a/packages/eve/src/memory/provider.ts +++ b/packages/eve/src/memory/provider.ts @@ -15,7 +15,7 @@ * }); * ``` * - * BM25 (`$smart`) recall at `turn.started` / `compaction.completed`, plus `save_memory` / + * BM25 (`$smart`) recall at `turn.started`, plus `save_memory` / * `search_memory` / `read_session` / `forget_memory` tools bound to the slot's locked scope. * Automatic capture of the caller's messages is on by default (`rememberMessages`). * @@ -41,7 +41,6 @@ import { AgentMemory, stableHash } from "@upstash/agentkit-sdk"; import { Redis, s } from "@upstash/redis"; import type { - MemoryCompactionCompletedContext, MemoryCompactionRequestedContext, MemoryOperationContext, MemoryProvider, @@ -55,8 +54,8 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; import { addTelemetry } from "../telemetry.js"; -/** Context shared by every recall handler this provider registers. */ -export type RedisMemoryRecallContext = MemoryTurnStartedContext | MemoryCompactionCompletedContext; +/** Context of the recall handler this provider registers (eve 0.84+ only has `turn.started`). */ +export type RedisMemoryRecallContext = MemoryTurnStartedContext; /** Context shared by every capture handler this provider registers. */ export type RedisMemoryCaptureContext = | MemoryTurnCompletedContext @@ -347,8 +346,8 @@ function latestModelTexts(messages: readonly ContextMessage[]): string[] { * a no-op: every memory's id is a hash of its text ({@link memoryIdFor}), so re-storing identical * text overwrites one Redis key instead of growing the store. * - * At `compaction.requested` the turn can be `null` (a standalone compaction with no active turn); - * there is no new user text then, so nothing is captured. + * (Historical: eve <0.84 had `compaction.requested`, where the turn could be `null`; + * there is no new user text then, so nothing is captured.) */ function defaultExtractMemories(context: RedisMemoryCaptureContext): string[] { return userTexts(context.turn?.input ?? []); @@ -492,7 +491,7 @@ function formatRecall( /** * 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 + * ranked (BM25 `$smart`) recall at `turn.started`, plus * `save_memory`/`search_memory`/`forget_memory` tools bound to the slot's locked scope. Automatic capture and * conversation capture are both opt-in. * @@ -542,9 +541,23 @@ export function redisMemory(config: RedisMemoryConfig = {}): MemoryProvider { const replayKey = (context: MemoryOperationContext): string => `${replayPrefix}:${toKeyPart(context.memory.scope.key)}:${toKeyPart(context.operationId)}`; + // eve 0.84+ gives a memory *tool* no `turn`, so `save_memory` cannot read the sequence its record + // is ordered by. `turn.started` runs first and does have it: it leaves the number here, keyed by + // scope + session, for the tools of the same turn to pick up. + const sequenceTtl = Math.max(replayTtl, 3_600); + const sequenceKey = (scopeKey: string, sessionId: string): string => + `${replayPrefix}:sequence:${toKeyPart(scopeKey)}:${toKeyPart(sessionId)}`; + const recall = async (context: RedisMemoryRecallContext): Promise => { context.abortSignal.throwIfAborted(); const userId = toKeyPart(context.memory.scope.key); + await redis.set( + sequenceKey(context.memory.scope.key, context.session.id), + context.turn.sequence, + { + ex: sequenceTtl, + }, + ); // Replay-stability first. eve records a digest of the accepted result and rejects a replay that // differs; a provider only needs its own cache when its store can change before that replay, @@ -659,7 +672,10 @@ export function redisMemory(config: RedisMemoryConfig = {}): MemoryProvider { sessionId: toKeyPart(context.session.id), source: "agent", deleted: false, - sequence: context.turn.sequence, + sequence: + (await redis.get( + sequenceKey(context.memory.scope.key, context.session.id), + )) ?? 0, subIndex: 0, }, }); @@ -827,7 +843,6 @@ export function redisMemory(config: RedisMemoryConfig = {}): MemoryProvider { return { recall: { "turn.started": recall, - "compaction.completed": recall, }, // No `compaction.requested`. It existed to grab facts before history was summarized away; once // every turn's messages are stored as they happen, nothing is lost at compaction and the hook diff --git a/packages/eve/src/sandbox.test.ts b/packages/eve/src/sandbox.test.ts index 00783c6..73c8158 100644 --- a/packages/eve/src/sandbox.test.ts +++ b/packages/eve/src/sandbox.test.ts @@ -1,8 +1,9 @@ import { config } from "dotenv"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Box } from "@upstash/box"; -import type { SandboxProviderImplementation } from "eve/sandbox/provider"; -import type { UpstashSandboxSession } from "./sandbox.js"; +import type { SandboxNetworkPolicy } from "eve/sandbox"; +import { SandboxSessionGoneError } from "eve/sandbox/provider"; +import type { SandboxConnection, SandboxProviderImplementation } from "eve/sandbox/provider"; import { UpstashSandbox, rewriteWorkspacePaths, @@ -14,11 +15,12 @@ import { config(); // load repo-root .env for UPSTASH_BOX_API_KEY const hasBoxCreds = Boolean(process.env.UPSTASH_BOX_API_KEY); +type Extensions = { setNetworkPolicy(policy: SandboxNetworkPolicy): Promise }; type Impl = SandboxProviderImplementation< { networkPolicy?: unknown } | undefined, { snapshotId: string | null }, { boxId: string; version: 1 }, - UpstashSandboxSession + Extensions >; /** @@ -27,7 +29,7 @@ type Impl = SandboxProviderImplementation< * since standing up the full eve runtime in a unit test isn't practical (the e2e eval does that). */ function implementationOf(options?: UpstashSandboxEnvironmentOptions): Impl { - const env = UpstashSandbox.environment(options) as unknown as Record; + const env = UpstashSandbox.environment(options ?? {}) as unknown as Record; for (const sym of Object.getOwnPropertySymbols(env)) { const v = env[sym] as { implementation?: Impl } | undefined; if (v && typeof v === "object" && v.implementation) return v.implementation; @@ -37,16 +39,24 @@ function implementationOf(options?: UpstashSandboxEnvironmentOptions): Impl { const host = {} as never; const sessionCtx = (id = "test-session") => - ({ host, session: { id }, storagePath: "/tmp/eve-test" }) as never; -const prepareCtx = (resources: unknown = { source: { kind: "none" } }) => + ({ host, session: { id }, kind: "session", storagePath: "/tmp/eve-test" }) as never; +/** What eve passes to `prepare()`; `populate` stands in for eve seeding `files` over a connection. */ +const prepareCtx = ( + overrides: { isEmpty?: boolean; populate?: (c: SandboxConnection) => Promise } = {}, +) => ({ files: { list: async () => [], read: async () => new Uint8Array(), readText: async () => "" }, - host, - resources, - sourceRevision: "rev-test", + isEmpty: true, storagePath: "/tmp/eve-test", + log: () => {}, + populate: async () => {}, + resolveProjectPath: (path: string) => path, + resourcesDirectory: async () => undefined, + templateKey: () => "abc123", + ...overrides, }) as never; const noArtifact = { snapshotId: null } as const; +const endOptions = { reason: "deleted" } as const; function fakeBox(calls: string[] = [], status = "idle") { return { @@ -55,9 +65,14 @@ function fakeBox(calls: string[] = [], status = "idle") { pause: vi.fn(async () => void calls.push("pause")), delete: vi.fn(async () => void calls.push("delete")), updateNetworkPolicy: vi.fn(async () => void calls.push("policy")), + files: { read: vi.fn(), write: vi.fn(async () => {}) }, + exec: { command: vi.fn(async () => ({ exitCode: 0, stdout: "", stderr: "", result: "" })) }, }; } +/** `Box.get` finding nothing — a session with no box yet. */ +const noExistingBox = () => vi.spyOn(Box, "get").mockRejectedValue(new Error("not found")); + afterEach(() => vi.restoreAllMocks()); describe("UpstashSandbox provider (offline)", () => { @@ -70,6 +85,7 @@ describe("UpstashSandbox provider (offline)", () => { expect(typeof impl.prepare).toBe("function"); expect(typeof impl.start).toBe("function"); expect(typeof impl.resume).toBe("function"); + expect(typeof impl.onSessionEnd).toBe("function"); }); // Eve roots its tools at /workspace; a Box session lives in /workspace/home. @@ -124,7 +140,31 @@ describe("UpstashSandbox provider (offline)", () => { expect(fromSnapshot).not.toHaveBeenCalled(); }); + it("prepare populates a temporary box over a connection, snapshots it and deletes the box", async () => { + const calls: string[] = []; + const box = { ...fakeBox(calls), snapshot: vi.fn(async () => ({ id: "snap_new" })) }; + vi.spyOn(Box, "create").mockResolvedValue(box as unknown as Box); + const populate = vi.fn(async (c: SandboxConnection) => { + // eve's seeding goes through the connection, so it must reach the box + await c.writeFiles({ + files: [{ path: "/workspace/seed.txt", content: new TextEncoder().encode("hi") }], + }); + }); + await expect( + implementationOf().prepare(prepareCtx({ isEmpty: false, populate })), + ).resolves.toEqual({ snapshotId: "snap_new" }); + expect(populate).toHaveBeenCalledOnce(); + expect(box.files.write).toHaveBeenCalledWith({ + path: "/workspace/home/seed.txt", + content: Buffer.from("hi").toString("base64"), + encoding: "base64", + }); + expect(box.snapshot).toHaveBeenCalledWith({ name: "eve-abc123" }); + expect(calls).toEqual(["delete"]); // the temporary box never outlives prepare + }); + it("start creates a deny-all, pause-on-idle box and returns minimal JSON state", async () => { + noExistingBox(); const box = fakeBox(); const create = vi.spyOn(Box, "create").mockResolvedValue(box as unknown as Box); const { state } = await implementationOf({ runtime: "node", size: "small" }).start( @@ -141,21 +181,41 @@ describe("UpstashSandbox provider (offline)", () => { keepAlive: false, networkPolicy: { mode: "deny-all" }, }); - expect(config.name).toMatch(/^eve-[0-9a-f]{12}-[0-9a-f]{6}$/); + expect(config.name).toMatch(/^eve-[0-9a-f]{16}$/); expect(box.updateNetworkPolicy).not.toHaveBeenCalled(); // default stays deny-all }); - it("start gives each start its own box name (names are account-wide)", async () => { + // eve requires start() to be an idempotent upsert: the same session must find its box again. + it("start names the box after the session and reuses a live one instead of creating another", async () => { + const get = noExistingBox(); const create = vi.spyOn(Box, "create").mockResolvedValue(fakeBox() as unknown as Box); const impl = implementationOf(); await impl.start(sessionCtx("s"), undefined, noArtifact); - await impl.start(sessionCtx("s"), undefined, noArtifact); + await impl.start(sessionCtx("other"), undefined, noArtifact); const [a, b] = create.mock.calls.map((c) => c[0]!.name!); - expect(a!.slice(0, 16)).toBe(b!.slice(0, 16)); // same session hash expect(a).not.toBe(b); + expect(get.mock.calls[0]![0]).toBe(a); // looked up by the same name it creates + + create.mockClear(); + get.mockResolvedValue({ ...fakeBox(), id: "box_existing" } as unknown as Box); + const again = await impl.start(sessionCtx("s"), undefined, noArtifact); + expect(again.state).toEqual({ boxId: "box_existing", version: 1 }); + expect(create).not.toHaveBeenCalled(); + }); + + it("start falls back to a suffixed name when a lingering record holds the session's name", async () => { + noExistingBox(); + const create = vi + .spyOn(Box, "create") + .mockRejectedValueOnce(new Error("name already taken")) + .mockResolvedValueOnce(fakeBox() as unknown as Box); + await implementationOf().start(sessionCtx("s"), undefined, noArtifact); + const [first, second] = create.mock.calls.map((c) => c[0]!.name!); + expect(second).toMatch(new RegExp(`^${first}-[0-9a-f]{6}$`)); }); it("start applies open({ networkPolicy }) to the new box", async () => { + noExistingBox(); const box = fakeBox(); vi.spyOn(Box, "create").mockResolvedValue(box as unknown as Box); await implementationOf().start(sessionCtx(), { networkPolicy: "allow-all" }, noArtifact); @@ -163,6 +223,7 @@ describe("UpstashSandbox provider (offline)", () => { }); it("start deletes the box when the open policy is unsupported (no orphan)", async () => { + noExistingBox(); const calls: string[] = []; const box = fakeBox(calls); vi.spyOn(Box, "create").mockResolvedValue(box as unknown as Box); @@ -176,6 +237,7 @@ describe("UpstashSandbox provider (offline)", () => { }); it("start restores from the prepared snapshot, and fails loudly if it's gone", async () => { + noExistingBox(); const fromSnapshot = vi .spyOn(Box, "fromSnapshot") .mockResolvedValueOnce(fakeBox() as unknown as Box) @@ -193,6 +255,7 @@ describe("UpstashSandbox provider (offline)", () => { }); it("start falls back to baseSnapshot when nothing was prepared", async () => { + noExistingBox(); const fromSnapshot = vi .spyOn(Box, "fromSnapshot") .mockResolvedValue(fakeBox() as unknown as Box); @@ -216,9 +279,12 @@ describe("UpstashSandbox provider (offline)", () => { await impl.resume(sessionCtx(), noArtifact, { boxId: "box_1", version: 1 }); expect(get).toHaveBeenCalledWith("box_1", { apiKey: "k" }); for (let i = 0; i < 2; i++) { - await expect( - impl.resume(sessionCtx(), noArtifact, { boxId: "box_1", version: 1 }), - ).rejects.toThrow(/sandbox\.delete\(\)/); + const err = await impl + .resume(sessionCtx(), noArtifact, { boxId: "box_1", version: 1 }) + .catch((e: unknown) => e); + // eve distinguishes a gone sandbox from any other resume failure by this class + expect(SandboxSessionGoneError.is(err)).toBe(true); + expect((err as Error).message).toMatch(/sandbox\.delete\(\)/); } expect(create).not.toHaveBeenCalled(); }); @@ -231,89 +297,190 @@ describe("UpstashSandbox provider (offline)", () => { ).rejects.toThrow(/artifact/); }); - // stop/shutdown PAUSE (compute released, box still resumable), delete DESTROYS the box. - it("maps stop/shutdown to Box pause and delete to Box delete", async () => { + // stop PAUSES (compute released, box still resumable); onSessionEnd DESTROYS the box. + it("maps connection.stop() to Box pause, propagating failures", async () => { + noExistingBox(); const calls: string[] = []; - vi.spyOn(Box, "create").mockResolvedValue(fakeBox(calls) as unknown as Box); - const { handle } = await implementationOf().start(sessionCtx(), undefined, noArtifact); + const box = fakeBox(calls); + vi.spyOn(Box, "create").mockResolvedValue(box as unknown as Box); + const { connection } = await implementationOf().start(sessionCtx(), undefined, noArtifact); + await connection.stop(); + expect(calls).toEqual(["pause"]); - await handle.onSandboxStop(); - await handle.onRuntimeShutdown(); - expect(calls).toEqual(["pause", "pause"]); + box.pause.mockRejectedValue(new Error("keep-alive boxes cannot be paused")); + await expect(connection.stop()).rejects.toThrow(/keep-alive/); + }); - await handle.onSandboxDelete(); - expect(calls).toEqual(["pause", "pause", "delete"]); + it("onSessionEnd deletes the recorded box, or finds it by session name without state", async () => { + const calls: string[] = []; + const get = vi.spyOn(Box, "get").mockResolvedValue(fakeBox(calls) as unknown as Box); + const impl = implementationOf({ apiKey: "k" }); - // Gone: repeat teardown is a no-op (eve can still call shutdown on a deleted handle). - await handle.onSandboxDelete(); - await handle.onSandboxStop(); - await handle.onRuntimeShutdown(); - expect(calls).toEqual(["pause", "pause", "delete"]); + await impl.onSessionEnd(sessionCtx("s"), { boxId: "box_1", version: 1 }, endOptions); + expect(get).toHaveBeenLastCalledWith("box_1", { apiKey: "k" }); + expect(calls).toEqual(["delete"]); + + await impl.onSessionEnd(sessionCtx("s"), null, endOptions); + expect(get.mock.lastCall![0]).toMatch(/^eve-[0-9a-f]{16}$/); + expect(calls).toEqual(["delete", "delete"]); }); - it("stop propagates pause failures; shutdown tolerates them", async () => { - const box = fakeBox(); - box.pause.mockRejectedValue(new Error("keep-alive boxes cannot be paused")); - vi.spyOn(Box, "create").mockResolvedValue(box as unknown as Box); - const { handle } = await implementationOf().start(sessionCtx(), undefined, noArtifact); - await expect(handle.onSandboxStop()).rejects.toThrow(/keep-alive/); - await expect(handle.onRuntimeShutdown()).resolves.toBeUndefined(); + it("onSessionEnd succeeds when nothing exists and skips an already-deleted box", async () => { + const calls: string[] = []; + const impl = implementationOf(); + vi.spyOn(Box, "get").mockRejectedValueOnce(new Error("not found")); + await expect(impl.onSessionEnd(sessionCtx(), null, endOptions)).resolves.toBeUndefined(); + + vi.spyOn(Box, "get").mockResolvedValueOnce(fakeBox(calls, "deleted") as unknown as Box); + await expect( + impl.onSessionEnd(sessionCtx(), { boxId: "box_1", version: 1 }, endOptions), + ).resolves.toBeUndefined(); + expect(calls).toEqual([]); }); - it("delete rejects an already-aborted signal without deleting", async () => { + it("onSessionEnd rejects an already-aborted signal without deleting", async () => { const calls: string[] = []; - vi.spyOn(Box, "create").mockResolvedValue(fakeBox(calls) as unknown as Box); - const { handle } = await implementationOf().start(sessionCtx(), undefined, noArtifact); - await expect(handle.onSandboxDelete({ abortSignal: AbortSignal.abort() })).rejects.toThrow(); + vi.spyOn(Box, "get").mockResolvedValue(fakeBox(calls) as unknown as Box); + await expect( + implementationOf().onSessionEnd(sessionCtx(), null, { + ...endOptions, + signal: AbortSignal.abort(), + }), + ).rejects.toThrow(); expect(calls).toEqual([]); }); + + describe("connection", () => { + async function connect(box = fakeBox()) { + noExistingBox(); + vi.spyOn(Box, "create").mockResolvedValue(box as unknown as Box); + const { connection } = await implementationOf().start(sessionCtx(), undefined, noArtifact); + return { box, connection }; + } + + it("writeFiles maps paths and sends base64", async () => { + const { box, connection } = await connect(); + await connection.writeFiles({ + files: [{ path: "/workspace/a/b.bin", content: new Uint8Array([0, 1, 255]) }], + }); + expect(box.files.write).toHaveBeenCalledWith({ + path: "/workspace/home/a/b.bin", + content: Buffer.from([0, 1, 255]).toString("base64"), + encoding: "base64", + }); + }); + + it("readFile returns null only for a missing path and rethrows otherwise", async () => { + const { box, connection } = await connect(); + box.files.read.mockResolvedValueOnce(Buffer.from("hi").toString("base64")); + expect(await readText(connection, "/workspace/x")).toBe("hi"); + + box.files.read.mockRejectedValueOnce(new Error("no such file")); + box.exec.command.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "", result: "" }); + expect(await connection.readFile({ path: "/workspace/missing" })).toBeNull(); + + box.files.read.mockRejectedValueOnce(new Error("is a directory")); + await expect(connection.readFile({ path: "/workspace" })).rejects.toThrow(/directory/); + }); + + it("removePath maps the path and fails when rm does", async () => { + const { box, connection } = await connect(); + await connection.removePath({ path: "/workspace/d", recursive: true, force: true }); + expect(box.exec.command).toHaveBeenCalledWith("rm -rf '/workspace/home/d'"); + box.exec.command.mockResolvedValueOnce({ + exitCode: 1, + stdout: "", + stderr: "nope", + result: "", + }); + await expect(connection.removePath({ path: "/workspace/d" })).rejects.toThrow(/nope/); + }); + + it("spawn refuses a writable stdin, which Box sessions cannot provide", async () => { + const { connection } = await connect(); + expect(() => + connection.spawn({ command: "cat", env: {}, workingDirectory: "/workspace", stdin: true }), + ).toThrow(/stdin/); + }); + + it("exposes setNetworkPolicy as a session extension", async () => { + const { box, connection } = await connect(); + await connection.extensions!.setNetworkPolicy("allow-all"); + expect(box.updateNetworkPolicy).toHaveBeenCalledWith({ mode: "allow-all" }); + }); + }); }); +/** Run a command over a connection and collect its output. */ +async function sh( + connection: SandboxConnection, + command: string, + options: { env?: Record; workingDirectory?: string } = {}, +) { + const p = await connection.spawn({ + command, + env: options.env ?? {}, + workingDirectory: options.workingDirectory ?? "/workspace", + }); + const text = async (s: ReadableStream) => new Response(s).text(); + const [stdout, stderr, { exitCode }] = await Promise.all([ + text(p.stdout), + text(p.stderr), + p.wait(), + ]); + return { exitCode, stdout, stderr }; +} + +const readText = async (connection: SandboxConnection, path: string) => { + const bytes = await connection.readFile({ path }); + return bytes === null ? null : new TextDecoder().decode(bytes as Uint8Array); +}; + describe.skipIf(!hasBoxCreds)("UpstashSandbox provider (live Upstash Box)", () => { const fetchCmd = `node -e "fetch('https://example.com').then(r=>process.exit(r.ok?0:9)).catch(()=>process.exit(7))"`; - - // The whole lifecycle eve drives, against real Box: prepare (workspace seed + skill + hook) → - // start from the snapshot → session ops → stop (pause) → resume by boxId → delete → resume fails. - it("prepare → start → stop → resume → delete", async () => { - const impl = implementationOf({ - runtime: "node", - size: "small", - async prepare(sandbox) { - await sandbox.writeTextFile({ path: "prepared.txt", content: "from-prepare" }); - }, - }); - const resources = { - source: { kind: "inline", key: "k" }, - workspace: { - key: "k:workspace", - mountPath: "/eve/resources/workspace", - targetPath: "/workspace", - files: [{ relativePath: "seed/README.txt", content: "seeded" }], - }, - skills: { - key: "k:skills", - mountPath: "/eve/resources/skills", - targetPath: "$HOME/.agents/skills", - files: [{ relativePath: "demo/SKILL.md", content: new TextEncoder().encode("# demo") }], - }, - }; - const artifact = await impl.prepare(prepareCtx(resources)); + const live = (id: string) => `${id}-${Math.random().toString(36).slice(2, 8)}`; + + // The whole lifecycle eve drives, against real Box: prepare (populate over a connection) → + // start from the snapshot → session ops → stop (pause) → resume by boxId → end → resume fails. + it("prepare → start → stop → resume → end", async () => { + const impl = implementationOf({ runtime: "node", size: "small" }); + const enc = new TextEncoder(); + const artifact = await impl.prepare( + prepareCtx({ + isEmpty: false, + // What eve's populate does: seed the workspace and skills, then run the authored prepare. + populate: async (c) => { + const home = (await sh(c, 'printf %s "$HOME"')).stdout; + await c.writeFiles({ + files: [ + { path: "/workspace/seed/README.txt", content: enc.encode("seeded") }, + { path: `${home}/.agents/skills/demo/SKILL.md`, content: enc.encode("# demo") }, + { path: "/workspace/prepared.txt", content: enc.encode("from-prepare") }, + ], + }); + }, + }), + ); expect(artifact.snapshotId).toEqual(expect.any(String)); + const sessionId = live("live"); const boxes: string[] = []; try { - const { handle, state } = await impl.start(sessionCtx("live"), undefined, artifact); + const { connection, state } = await impl.start(sessionCtx(sessionId), undefined, artifact); boxes.push(state.boxId); - const s = handle.sandbox; // Everything prepare baked is in the session's box, at eve's paths. - expect(await s.readTextFile({ path: "/workspace/seed/README.txt" })).toBe("seeded"); - expect(await s.readTextFile({ path: "prepared.txt" })).toBe("from-prepare"); - const skill = await s.run({ command: "cat $HOME/.agents/skills/demo/SKILL.md" }); - expect(skill.stdout).toBe("# demo"); - - // run: separate stdout/stderr, exit code, env reaches every command, cwd is honoured. - const r = await s.run({ - command: "echo out; echo err >&2; echo $A; pwd; exit 3", + expect(await readText(connection, "/workspace/seed/README.txt")).toBe("seeded"); + expect(await readText(connection, "/workspace/prepared.txt")).toBe("from-prepare"); + expect((await sh(connection, "cat $HOME/.agents/skills/demo/SKILL.md")).stdout).toBe( + "# demo", + ); + + // start is idempotent: the same session finds the same box. + const again = await impl.start(sessionCtx(sessionId), undefined, artifact); + expect(again.state.boxId).toBe(state.boxId); + + // spawn: separate stdout/stderr, exit code, env reaches every command, cwd is honoured. + const r = await sh(connection, "echo out; echo err >&2; echo $A; pwd; exit 3", { env: { A: "it's set" }, workingDirectory: "/workspace/seed", }); @@ -324,28 +491,38 @@ describe.skipIf(!hasBoxCreds)("UpstashSandbox provider (live Upstash Box)", () = }); // Egress: deny-all by default, opened per session. - expect((await s.run({ command: fetchCmd })).exitCode).not.toBe(0); - await s.setNetworkPolicy("allow-all"); - expect((await s.run({ command: fetchCmd })).exitCode).toBe(0); + expect((await sh(connection, fetchCmd)).exitCode).not.toBe(0); + await connection.extensions!.setNetworkPolicy("allow-all"); + expect((await sh(connection, fetchCmd)).exitCode).toBe(0); - // Files round-trip (text + binary), removePath works. - await s.writeBinaryFile({ path: "bin/x.bin", content: new Uint8Array([0, 1, 255]) }); - expect(Array.from((await s.readBinaryFile({ path: "bin/x.bin" }))!)).toEqual([0, 1, 255]); - await s.removePath({ path: "bin", recursive: true, force: true }); - expect(await s.readBinaryFile({ path: "bin/x.bin" })).toBeNull(); + // Files round-trip (binary), removePath works, and a missing path reads as null. + await connection.writeFiles({ + files: [{ path: "bin/x.bin", content: new Uint8Array([0, 1, 255]) }], + }); + expect(Array.from((await connection.readFile({ path: "bin/x.bin" })) as Uint8Array)).toEqual([ + 0, 1, 255, + ]); + await connection.removePath({ path: "bin", recursive: true, force: true }); + expect(await connection.readFile({ path: "bin/x.bin" })).toBeNull(); // stop = pause; resume reattaches the SAME box (state survives), network policy included. - await s.writeTextFile({ path: "turn1.txt", content: "kept" }); - await handle.onSandboxStop(); - const resumed = await impl.resume(sessionCtx("live"), artifact, state); - expect(await resumed.sandbox.readTextFile({ path: "turn1.txt" })).toBe("kept"); - expect((await resumed.sandbox.run({ command: fetchCmd })).exitCode).toBe(0); - - // delete = gone for good; resuming it now fails instead of silently making a new box. - await resumed.onSandboxDelete(); - await expect(impl.resume(sessionCtx("live"), artifact, state)).rejects.toThrow( - /no longer available/, - ); + await connection.writeFiles({ + files: [{ path: "turn1.txt", content: enc.encode("kept") }], + }); + await connection.stop(); + const resumed = await impl.resume(sessionCtx(sessionId), artifact, state); + expect(await readText(resumed, "turn1.txt")).toBe("kept"); + expect((await sh(resumed, fetchCmd)).exitCode).toBe(0); + + // end = gone for good; resuming it now fails instead of silently making a new box. + await impl.onSessionEnd(sessionCtx(sessionId), state, { reason: "deleted" }); + const err = await impl + .resume(sessionCtx(sessionId), artifact, state) + .catch((e: unknown) => e); + expect(SandboxSessionGoneError.is(err)).toBe(true); + // ...and ending again, or ending a session that never recorded state, is a no-op. + await impl.onSessionEnd(sessionCtx(sessionId), state, { reason: "deleted" }); + await impl.onSessionEnd(sessionCtx(live("never")), null, { reason: "deleted" }); } finally { await Box.delete({ boxIds: boxes }).catch(() => {}); await Box.deleteSnapshots({ snapshotIds: artifact.snapshotId! }).catch(() => {}); @@ -353,12 +530,19 @@ describe.skipIf(!hasBoxCreds)("UpstashSandbox provider (live Upstash Box)", () = }, 240_000); // spawn streams output and is killable; eve's turn abort signal kills a running command. - it("spawn streams and kills; an aborted run kills its process", async () => { + it("spawn streams and kills; an aborted spawn kills its process", async () => { const impl = implementationOf({ runtime: "node" }); - const { handle, state } = await impl.start(sessionCtx("spawn"), undefined, noArtifact); + const { connection, state } = await impl.start( + sessionCtx(live("spawn")), + undefined, + noArtifact, + ); try { - const s = handle.sandbox; - const p = await s.spawn({ command: "echo first; sleep 30; echo never" }); + const p = await connection.spawn({ + command: "echo first; sleep 30; echo never", + env: {}, + workingDirectory: "/workspace", + }); const reader = p.stdout.getReader(); const { value } = await reader.read(); expect(new TextDecoder().decode(value)).toContain("first"); // streamed before exit @@ -369,7 +553,16 @@ describe.skipIf(!hasBoxCreds)("UpstashSandbox provider (live Upstash Box)", () = const ac = new AbortController(); const started = Date.now(); setTimeout(() => ac.abort(), 1_000); - await expect(s.run({ command: "sleep 30", abortSignal: ac.signal })).rejects.toThrow(); + const sleeper = await connection.spawn({ + command: "sleep 30", + env: {}, + workingDirectory: "/workspace", + signal: ac.signal, + }); + await sleeper.wait().then( + ({ exitCode }) => expect(exitCode).not.toBe(0), + () => {}, // a killed process may also reject + ); expect(Date.now() - started).toBeLessThan(15_000); } finally { await Box.delete({ boxIds: state.boxId }).catch(() => {}); @@ -383,18 +576,18 @@ describe.skipIf(!hasBoxCreds)("UpstashSandbox provider (live Upstash Box)", () = runtime: "node", attachHeaders: { "example.com": { Authorization: "Bearer test" } }, }); - const { handle, state } = await impl.start( - sessionCtx("net"), + const { connection, state } = await impl.start( + sessionCtx(live("net")), { networkPolicy: { allow: ["example.com"] } }, noArtifact, ); try { await expect( - handle.sandbox.setNetworkPolicy({ + connection.extensions!.setNetworkPolicy({ allow: { "example.com": [{ transform: [{ headers: { authorization: "x" } }] }] }, } as never), ).rejects.toThrow(/attachHeaders/); - expect((await handle.sandbox.run({ command: fetchCmd })).exitCode).toBe(0); + expect((await sh(connection, fetchCmd)).exitCode).toBe(0); } finally { await Box.delete({ boxIds: state.boxId }).catch(() => {}); } diff --git a/packages/eve/src/sandbox.ts b/packages/eve/src/sandbox.ts index e74b122..29a107a 100644 --- a/packages/eve/src/sandbox.ts +++ b/packages/eve/src/sandbox.ts @@ -2,7 +2,7 @@ * Sandbox provider for **Eve** (`eve/sandbox`, https://eve.dev/docs/sandbox), powered by * **Upstash Box** (`@upstash/box`) — a serverless cloud sandbox for AI agents. * - * `UpstashSandbox` is an Eve sandbox **provider** (eve ≥0.64's `defineSandboxProvider()` contract), + * `UpstashSandbox` is an Eve sandbox **provider** (eve ≥0.87's `defineSandboxProvider()` contract), * used exactly like eve's built-in `VercelSandbox`: * * ```ts @@ -60,12 +60,8 @@ import { createHash, randomBytes } from "node:crypto"; import { Box } from "@upstash/box"; import type { BoxConfig, NetworkPolicy as BoxNetworkPolicy } from "@upstash/box"; import type { SandboxNetworkPolicy, SandboxProcess, SandboxSession } from "eve/sandbox"; -import { defineSandboxProvider } from "eve/sandbox/provider"; -import type { - SandboxDeleteOptions, - SandboxProviderHandle, - SandboxProviderResources, -} from "eve/sandbox/provider"; +import { SandboxSessionGoneError, defineSandboxProvider } from "eve/sandbox/provider"; +import type { SandboxConnection } from "eve/sandbox/provider"; /** * The session type of an Upstash Box sandbox: eve's {@link SandboxSession} plus `setNetworkPolicy`. @@ -86,7 +82,11 @@ export interface UpstashSandboxOpenOptions { /** * Options for `UpstashSandbox.environment(...)` — the **Upstash Box** `BoxConfig` as-is (`runtime`, * `size`, `apiKey`/`baseUrl`, `keepAlive`, `initCommand`, `env`, `git`, `skills`, `mcpServers`, - * `attachHeaders`, `timeout`, …), plus two AgentKit fields: `prepare` and `baseSnapshot`. + * `attachHeaders`, `timeout`, …), plus the AgentKit field `baseSnapshot`. eve adds the standard `prepare(sandbox)` option itself: + * setup every session's box inherits (installing packages, cloning a repo, warming caches), run once + * per environment generation inside a temporary box and captured as a Box snapshot. The box starts + * deny-all, so call `sandbox.setNetworkPolicy("allow-all")` (or an allow-list) first if the setup + * needs egress; sessions do **not** inherit the policy you set there. * * Omitted from `BoxConfig`: `networkPolicy` (egress is deny-all at creation and opened per session — * see {@link UpstashSandboxOpenOptions}) and `name` (box names are unique per account, and every @@ -97,14 +97,6 @@ export interface UpstashSandboxOpenOptions { * `UPSTASH_BOX_API_KEY` over an inline `apiKey` so rotating the key doesn't do that. */ export type UpstashSandboxEnvironmentOptions = Omit & { - /** - * Setup every session's box inherits — installing packages, cloning a repo, warming caches. Runs - * once per environment generation inside a temporary box (after workspace seeds and skills are - * written); the result is captured as a Box snapshot. The box starts deny-all: call - * `sandbox.setNetworkPolicy("allow-all")` (or an allow-list) first if the setup needs egress. - * Sessions do **not** inherit the policy you set here. - */ - prepare?: (sandbox: UpstashSandboxSession) => Promise | void; /** * A **base Box snapshot** to build on instead of a bare `Box.create` — for heavy, slow-changing * setup (browser binaries, a toolchain) you bake and manage yourself, out of band. `prepare` layers @@ -204,8 +196,8 @@ export function toBoxNetworkPolicy(policy: SandboxNetworkPolicy): BoxNetworkPoli return { mode: "custom", ...(allowedDomains ? { allowedDomains } : {}), - ...(policy.subnets?.allow ? { allowedCidrs: policy.subnets.allow } : {}), - ...(policy.subnets?.deny ? { deniedCidrs: policy.subnets.deny } : {}), + ...(policy.subnets?.allow ? { allowedCidrs: [...policy.subnets.allow] } : {}), + ...(policy.subnets?.deny ? { deniedCidrs: [...policy.subnets.deny] } : {}), }; } @@ -217,37 +209,6 @@ const toBase64 = (data: string | Uint8Array): string => Buffer.from(data as Uint8Array).toString("base64"); const fromBase64 = (b64: string): Uint8Array => new Uint8Array(Buffer.from(b64, "base64")); -function bytesToStream(bytes: Uint8Array): ReadableStream { - return new ReadableStream({ - start(controller) { - controller.enqueue(bytes); - controller.close(); - }, - }); -} - -async function streamToBytes(stream: ReadableStream): Promise { - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - if (value) chunks.push(value); - } - const total = chunks.reduce((n, c) => n + c.length, 0); - const out = new Uint8Array(total); - let offset = 0; - for (const c of chunks) { - out.set(c, offset); - offset += c.length; - } - return out; -} - -async function streamToText(stream: ReadableStream): Promise { - return new TextDecoder().decode(await streamToBytes(stream)); -} - /** A writable byte stream plus its controller, closed exactly once. */ function pushStream(): { stream: ReadableStream; @@ -278,8 +239,8 @@ function pushStream(): { interface ProcessOptions { command: string; workingDirectory?: string; - env?: Record; - abortSignal?: AbortSignal; + env?: Readonly>; + signal?: AbortSignal; } /** @@ -288,7 +249,7 @@ interface ProcessOptions { * tree, so a cancelled turn doesn't leave a runaway command in the box. */ async function spawnOnBox(box: Box, options: ProcessOptions): Promise { - options.abortSignal?.throwIfAborted(); + options.signal?.throwIfAborted(); const stdout = pushStream(); const stderr = pushStream(); const handle = await box.exec.session({ @@ -301,16 +262,16 @@ async function spawnOnBox(box: Box, options: ProcessOptions): Promise handle.kill("KILL"); - options.abortSignal?.addEventListener("abort", onAbort, { once: true }); + options.signal?.addEventListener("abort", onAbort, { once: true }); const exited = handle.wait().then( (exitCode) => { - options.abortSignal?.removeEventListener("abort", onAbort); + options.signal?.removeEventListener("abort", onAbort); stdout.end(); stderr.end(); return { exitCode }; }, (err: unknown) => { - options.abortSignal?.removeEventListener("abort", onAbort); + options.signal?.removeEventListener("abort", onAbort); stdout.end(err); stderr.end(err); throw err; @@ -330,121 +291,64 @@ async function spawnOnBox(box: Box, options: ProcessOptions): Promise toBoxPath(path); - +/** Build eve's byte-level {@link SandboxConnection} over a live Box; eve derives the session from it. */ +function buildConnection(box: Box): SandboxConnection<{ + setNetworkPolicy(policy: SandboxNetworkPolicy): Promise; +}> { return { - resolvePath, - spawn: (options) => spawnOnBox(box, options), - async run(options) { - const proc = await spawnOnBox(box, options); - const [stdout, stderr, { exitCode }] = await Promise.all([ - streamToText(proc.stdout), - streamToText(proc.stderr), - proc.wait(), - ]); - options.abortSignal?.throwIfAborted(); - return { exitCode, stdout, stderr }; - }, - async readFile({ path }) { - try { - return bytesToStream( - fromBase64(await box.files.read(resolvePath(path), { encoding: "base64" })), + // Eve hands us `/workspace`-rooted paths; map them to Box's `/workspace/home`. + spawn(options) { + if (options.stdin) { + throw new Error( + "UpstashSandbox: spawning a process with a writable stdin is not supported.", ); - } catch { - return null; } + return spawnOnBox(box, options); }, - async readBinaryFile({ path }) { + async readFile({ path }) { + const boxPath = toBoxPath(path); try { - return fromBase64(await box.files.read(resolvePath(path), { encoding: "base64" })); - } catch { - return null; + return fromBase64(await box.files.read(boxPath, { encoding: "base64" })); + } catch (err) { + // `null` is reserved for a path that does not exist; a directory or unreadable path rejects. + const probe = await box.exec.command(`test -e ${shellQuote(boxPath)}`); + if ((probe.exitCode ?? 0) !== 0) return null; + throw err; } }, - async readTextFile({ path, startLine, endLine }) { - try { - const text = await box.files.read(resolvePath(path)); - if (startLine === undefined && endLine === undefined) return text; - const lines = text.split("\n"); - return lines.slice((startLine ?? 1) - 1, endLine ?? lines.length).join("\n"); - } catch { - return null; + async writeFiles({ files, signal }) { + for (const file of files) { + signal?.throwIfAborted(); + await box.files.write({ + path: toBoxPath(file.path), + content: toBase64(file.content), + encoding: "base64", + }); } }, - async writeFile({ path, content }) { - const bytes = await streamToBytes(content); - await box.files.write({ - path: resolvePath(path), - content: toBase64(bytes), - encoding: "base64", - }); - }, - async writeBinaryFile({ path, content }) { - await box.files.write({ - path: resolvePath(path), - content: toBase64(content), - encoding: "base64", - }); - }, - async writeTextFile({ path, content }) { - await box.files.write({ path: resolvePath(path), content }); - }, - async setNetworkPolicy(policy) { - await box.updateNetworkPolicy(toBoxNetworkPolicy(policy)); - }, async removePath({ path, force, recursive }) { const flags = `${recursive ? "r" : ""}${force ? "f" : ""}`; const run = await box.exec.command( - `rm ${flags ? `-${flags}` : ""} ${shellQuote(resolvePath(path))}`.trim(), + `rm ${flags ? `-${flags}` : ""} ${shellQuote(toBoxPath(path))}`.trim(), ); if ((run.exitCode ?? 0) !== 0) { throw new Error(`UpstashSandbox: removing "${path}" failed: ${run.stderr || run.result}`); } }, + // `sandbox.stop()`: release compute, keep the box resumable (`resume` -> `Box.get` auto-resumes). + // Provider errors must reject: keep-alive boxes can't be paused and will. + async stop() { + await box.pause(); + }, + extensions: { + async setNetworkPolicy(policy) { + await box.updateNetworkPolicy(toBoxNetworkPolicy(policy)); + }, + }, }; } -/** - * Eve's compiled workspace seeds and skills, flattened to absolute target paths (workspace files land - * under `/workspace`, skills under `$HOME/.agents/skills`). - */ -function resourceFiles( - resources: SandboxProviderResources, -): { path: string; content: string | Uint8Array }[] { - return [resources.workspace, resources.skills].flatMap((tree) => - tree === undefined - ? [] - : tree.files.map((f) => ({ - path: `${tree.targetPath}/${f.relativePath}`, - content: f.content, - })), - ); -} - -/** Write eve's resources into a box, expanding `$HOME` and bridging `/workspace` → `/workspace/home`. */ -async function writeResources( - box: Box, - files: { path: string; content: string | Uint8Array }[], -): Promise { - let home: string | undefined; - for (const file of files) { - let path = file.path; - if (path.startsWith("$HOME/")) { - home ??= (await box.exec.command('printf %s "$HOME"')).stdout.trim(); - if (!home) throw new Error("UpstashSandbox: could not resolve $HOME inside the box."); - path = `${home}/${path.slice("$HOME/".length)}`; - } - const binary = typeof file.content !== "string"; - await box.files.write({ - path: toBoxPath(path), - content: binary ? toBase64(file.content) : (file.content as string), - ...(binary ? { encoding: "base64" as const } : {}), - }); - } -} +const SNAPSHOT_RESTORE_FAILED = "UpstashSandbox: could not restore Upstash Box snapshot"; function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); @@ -470,33 +374,19 @@ function requireState(state: unknown): UpstashSandboxState { return s as UpstashSandboxState; } -/** Provider handle over a live box: the session plus eve's lifecycle hooks. */ -function createHandle(box: Box): SandboxProviderHandle { - // Set once `onSandboxDelete` destroyed the box: eve may still call stop/shutdown on the handle. - let deleted = false; - return { - sandbox: buildSession(box), - // `sandbox.stop()`: release compute, keep the box resumable (`resume` → `Box.get` auto-resumes). - // Provider errors must reject — keep-alive boxes can't be paused and will. - async onSandboxStop() { - if (deleted) return; - await box.pause(); - }, - // Server stopping: nothing may be left running, but failures must not block teardown. - async onRuntimeShutdown() { - if (deleted) return; - await box.pause().catch(() => {}); - }, - // `sandbox.delete()`: permanently remove the session's box. The prepared snapshot is shared by - // every session of the environment and must survive. Errors reject so eve keeps the state and - // authored code can retry. - async onSandboxDelete(options?: SandboxDeleteOptions) { - if (deleted) return; - options?.abortSignal?.throwIfAborted(); // Box's API takes no signal; honour it at the boundary - await box.delete(); - deleted = true; - }, - }; +/** A box's `getStatus()` for a lookup: `null` when it is deleted, errored or cannot be fetched. */ +async function liveBox( + lookup: () => Promise, +): Promise<{ box: Box; error?: undefined } | { box?: undefined; error: unknown }> { + try { + const box = await lookup(); + // `Box.get` also returns deleted boxes (their record lingers with that status), so check. + const { status } = await box.getStatus(); + if (status === "deleted" || status === "error") throw new Error(`box status is "${status}"`); + return { box }; + } catch (error) { + return { error }; + } } /** @@ -505,15 +395,16 @@ function createHandle(box: Box): SandboxProviderHandle { * `defineSandbox()`. */ export const UpstashSandbox = defineSandboxProvider< - UpstashSandboxEnvironmentOptions | undefined, + UpstashSandboxEnvironmentOptions, UpstashSandboxOpenOptions | undefined, UpstashSandboxArtifact, UpstashSandboxState, - UpstashSandboxSession + { setNetworkPolicy(policy: SandboxNetworkPolicy): Promise } >({ name: "upstash", environment(options) { - const { prepare: prepareHook, baseSnapshot, ...boxOptions } = options ?? {}; + // `prepare` never reaches the provider: eve strips it and runs it inside `ctx.populate()`. + const { baseSnapshot, ...boxOptions } = options; /** `BoxConfig` for new boxes: the user's options, pause-based idle, deny-all egress, a name. */ const boxConfig = (name: string): BoxConfig => ({ @@ -545,37 +436,46 @@ export const UpstashSandbox = defineSandboxProvider< return await Box.fromSnapshot(snapshotId, boxConfig(name)); } catch (err) { throw new Error( - `UpstashSandbox: could not restore Upstash Box snapshot "${snapshotId}" (${errorMessage(err)}). ` + + `${SNAPSHOT_RESTORE_FAILED} "${snapshotId}" (${errorMessage(err)}). ` + `If it was deleted, rebuild (\`eve build\`) or redeploy to prepare the environment again.`, { cause: err }, ); } }; + /** + * The box name derived from the session id. eve requires `start()` to be an idempotent upsert + * and `onSessionEnd()` to delete "whatever exists for the session" even with no recorded state, + * so a session's box must be findable by something other than a stored id. + */ + const sessionBoxName = (sessionId: string): string => + `eve-${createHash("sha256").update(sessionId).digest("hex").slice(0, 16)}`; + return { async prepare(ctx) { - const files = resourceFiles(ctx.resources); - // Nothing to bake → no temporary box, no snapshot; sessions start from the base (if any). - if (files.length === 0 && !prepareHook) return { snapshotId: null }; + // Nothing to bake (no seeds, skills or authored `prepare`) -> no temporary box, no snapshot; + // sessions start from the base (if any). + if (ctx.isEmpty) return { snapshotId: null }; const box = await createBox( `eve-prep-${randomBytes(4).toString("hex")}`, await resolveBaseSnapshot(), ); try { - await writeResources(box, files); - if (prepareHook) await prepareHook(buildSession(box)); + // Seeds `/workspace` and `$HOME/.agents/skills`, then runs the authored `prepare` with a + // session over this connection. + await ctx.populate(buildConnection(box)); const snapshot = await box.snapshot({ - name: `eve-${ctx.sourceRevision}`.replace(/[^\w.-]/g, "-").slice(0, 120), + name: `eve-${ctx.templateKey({ provider: "upstash" })}` + .replace(/[^\w.-]/g, "-") + .slice(0, 120), }); - ctx.log?.(`Prepared Upstash Box snapshot ${snapshot.id}`); + ctx.log(`Prepared Upstash Box snapshot ${snapshot.id}`); return { snapshotId: snapshot.id }; } catch (err) { throw new Error( `UpstashSandbox: failed to prepare the sandbox environment: ${errorMessage(err)}`, - { - cause: err, - }, + { cause: err }, ); } finally { // Snapshots outlive the box they were taken from. @@ -585,13 +485,35 @@ export const UpstashSandbox = defineSandboxProvider< async start(ctx, openOptions, artifact) { const { snapshotId } = requireArtifact(artifact); - // Unique per start (box names are account-wide); the session hash makes boxes traceable - // to their eve session in the Box console. - const sessionTag = createHash("sha256").update(ctx.session.id).digest("hex").slice(0, 12); - const box = await createBox( - `eve-${sessionTag}-${randomBytes(3).toString("hex")}`, - snapshotId ?? (await resolveBaseSnapshot()), - ); + const name = sessionBoxName(ctx.session.id); + + // Idempotent upsert: a repeated or concurrent start for the same session finds its box. + const existing = await liveBox(() => Box.get(name, connection)); + if (existing.box) { + return { + connection: buildConnection(existing.box), + state: { boxId: existing.box.id, version: 1 }, + }; + } + + const restoreFrom = snapshotId ?? (await resolveBaseSnapshot()); + let box: Box; + try { + box = await createBox(name, restoreFrom); + } catch (err) { + // Lost a race with a concurrent start, or a deleted box still holds the name. + const raced = await liveBox(() => Box.get(name, connection)); + if (raced.box) { + return { + connection: buildConnection(raced.box), + state: { boxId: raced.box.id, version: 1 }, + }; + } + // Box names are account-wide: a lingering deleted record keeps its name, so retry with a + // suffix. (`onSessionEnd` finds this box through the recorded state.) + if (errorMessage(err).startsWith(SNAPSHOT_RESTORE_FAILED)) throw err; + box = await createBox(`${name}-${randomBytes(3).toString("hex")}`, restoreFrom); + } try { if (openOptions?.networkPolicy) { await box.updateNetworkPolicy(toBoxNetworkPolicy(openOptions.networkPolicy)); @@ -601,27 +523,37 @@ export const UpstashSandbox = defineSandboxProvider< await box.delete().catch(() => {}); throw err; } - return { handle: createHandle(box), state: { boxId: box.id, version: 1 } }; + return { connection: buildConnection(box), state: { boxId: box.id, version: 1 } }; }, async resume(_ctx, artifact, state) { requireArtifact(artifact); const { boxId } = requireState(state); - let box: Box; - try { - box = await Box.get(boxId, connection); - // `Box.get` also returns deleted boxes (their record lingers with that status), so check. - const { status } = await box.getStatus(); - if (status === "deleted" || status === "error") - throw new Error(`box status is "${status}"`); - } catch (err) { - throw new Error( + const found = await liveBox(() => Box.get(boxId, connection)); + if (!found.box) { + throw new SandboxSessionGoneError( `UpstashSandbox: the session's Upstash Box "${boxId}" is no longer available ` + - `(${errorMessage(err)}). Call sandbox.delete() to start a fresh sandbox for this session.`, - { cause: err }, + `(${errorMessage(found.error)}). Call sandbox.delete() to start a fresh sandbox for this session.`, ); } - return createHandle(box); + return buildConnection(found.box); + }, + + // `sandbox.delete()` and session end: permanently remove the session's box. The prepared + // snapshot is shared by every session of the environment and must survive. Must succeed when + // nothing exists; real failures reject so eve keeps the state and authored code can retry. + async onSessionEnd(ctx, state, endOptions) { + endOptions.signal?.throwIfAborted(); // Box's API takes no signal; honour it at the boundary + const target = state ? requireState(state).boxId : sessionBoxName(ctx.session.id); + let box: Box; + try { + box = await Box.get(target, connection); + } catch { + return; // nothing exists + } + const { status } = await box.getStatus().catch(() => ({ status: "deleted" })); + if (status === "deleted") return; + await box.delete(); }, }; }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f64158..1cf5d23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,11 +133,11 @@ importers: specifier: ^1.38.4 version: 1.38.4 '@vercel/connect': - specifier: 0.2.2 - version: 0.2.2(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.154(zod@4.4.3))(eve@0.82.0(@opentelemetry/api@1.9.1)(ai@7.0.154(zod@4.4.3))) + specifier: 2.5.6 + version: 2.5.6(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.156(zod@4.4.3))(eve@0.87.0) ai: - specifier: 7.0.154 - version: 7.0.154(zod@4.4.3) + specifier: 7.0.156 + version: 7.0.156(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.82.0 - version: 0.82.0(@opentelemetry/api@1.9.1)(ai@7.0.154(zod@4.4.3)) + specifier: ^0.87.0 + version: 0.87.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.156(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(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) @@ -160,8 +160,8 @@ importers: specifier: 5.1.11 version: 5.1.11 next: - specifier: 16.2.6 - version: 16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 16.2.9 + version: 16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) radix-ui: specifier: 1.4.3 version: 1.4.3(@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) @@ -379,14 +379,14 @@ importers: specifier: ^1.38.4 version: 1.38.4 ai: - specifier: 7.0.154 - version: 7.0.154(zod@4.4.3) + specifier: 7.0.156 + version: 7.0.156(zod@4.4.3) dotenv: specifier: ^16.4.5 version: 16.6.1 eve: - specifier: ^0.82.0 - version: 0.82.0(@opentelemetry/api@1.9.1)(ai@7.0.154(zod@4.4.3)) + specifier: ^0.87.0 + version: 0.87.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.156(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) packages/eve-extension: dependencies: @@ -491,12 +491,6 @@ packages: zod: optional: true - '@ai-sdk/gateway@4.0.127': - resolution: {integrity: sha512-ENcIQNrZxgFnNSx29X2o/mLKZRzMN91c53Qlp/UrF4RQGVSfaROjFQAq4eeJliRPWwjHvEhJYG42IrOwtWUGOQ==} - engines: {node: '>=22'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@4.0.129': resolution: {integrity: sha512-8JvaQBaIfM8jo54kg+picQpY9qBzP/hG+YIXJO9KDL+9RpdoTjzp0/mH7XjfaqNMHAf85dM2/tCBDOOyBN7Bgg==} engines: {node: '>=22'} @@ -521,12 +515,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@5.0.69': - resolution: {integrity: sha512-oGCXIoBKLpNrrAzJvltFHdBl20FJMyT+7edD9D4nwxoI8Scbj3y1EgKzjoF2XFhNYkMTcv1oTFDgUzLxdjqSKg==} - engines: {node: '>=22'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@5.0.70': resolution: {integrity: sha512-nU213UNVHWDoEKIZ5Hm3uGPmfW7EBHC2GkIJJEDKh4HLQWgh/eizrW2jmY4efxWf7Tf/yTzKD0t6sd4kVUKlyQ==} engines: {node: '>=22'} @@ -1170,43 +1158,21 @@ packages: resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==} engines: {node: '>=20'} - '@next/env@16.2.6': - resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} - '@next/env@16.2.9': resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} - '@next/swc-darwin-arm64@16.2.6': - resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - '@next/swc-darwin-arm64@16.2.9': resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.6': - resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - '@next/swc-darwin-x64@16.2.9': resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.6': - resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@next/swc-linux-arm64-gnu@16.2.9': resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} engines: {node: '>= 10'} @@ -1214,13 +1180,6 @@ packages: os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.6': - resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - '@next/swc-linux-arm64-musl@16.2.9': resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} engines: {node: '>= 10'} @@ -1228,13 +1187,6 @@ packages: os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.6': - resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - '@next/swc-linux-x64-gnu@16.2.9': resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} engines: {node: '>= 10'} @@ -1242,13 +1194,6 @@ packages: os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.6': - resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - '@next/swc-linux-x64-musl@16.2.9': resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} engines: {node: '>= 10'} @@ -1256,24 +1201,12 @@ packages: os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.6': - resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - '@next/swc-win32-arm64-msvc@16.2.9': resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.6': - resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - '@next/swc-win32-x64-msvc@16.2.9': resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==} engines: {node: '>= 10'} @@ -3064,40 +2997,13 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@vercel/cli-config@0.2.0': - resolution: {integrity: sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ==} - '@vercel/cli-config@0.3.4': resolution: {integrity: sha512-lkAn8NEC2I1EKN/Ni0M8b/VwWVMzZEcaF7lzT/B5NF51+5vPUx8bEkZGYqWmMUCVO2HFUyWCcX2pZlRDPrUsKQ==} - '@vercel/cli-exec@0.1.1': - resolution: {integrity: sha512-LMRMEai3Z+BODyxGcU9+KiWrS/UElNiOLKiNRfGNt2Vu3NTEmXgFeXG9wBfocAnTe5yJCX/DY6k3k7S/LkPp/g==} - engines: {node: '>= 18'} - '@vercel/cli-exec@1.0.1': resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==} engines: {node: '>= 18'} - '@vercel/connect@0.2.2': - resolution: {integrity: sha512-XJti9hGkjoMp1BrHIbAsTyl7kseALOWtizYe6S3C3RAq8xDW4OcdLVPYjowkqE0KRuIzhIzIwtpHPfqohqLG1w==} - peerDependencies: - '@ai-sdk/mcp': ^1 || ^2 - '@auth/core': '>=0.37.0' - ai: ^6 || ^7 - better-auth: '>=1.5.0' - eve: '>=0.6.0-beta.1' - peerDependenciesMeta: - '@ai-sdk/mcp': - optional: true - '@auth/core': - optional: true - ai: - optional: true - better-auth: - optional: true - eve: - optional: true - '@vercel/connect@2.5.6': resolution: {integrity: sha512-CmFeAQEuUU1FWmsrb3mnJuVGrGxh7XFpRPoq0sbr2brGZP5AZyukCXEH7I2xrcWgoyE2cj4j0lE+CEz9enuhYA==} peerDependencies: @@ -3128,10 +3034,6 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} - '@vercel/oidc@3.6.1': - resolution: {integrity: sha512-8ipTFoiX3WBRrvXLjSrmgAiwtMDQk3EgSxe8N7v2rXBz39NBIIyoGXeVbJRoBcP8WEuVnvjvIQsggbGU7ZKrMw==} - engines: {node: '>= 20'} - '@vercel/oidc@4.0.4': resolution: {integrity: sha512-cWw+jlAXo7vFHU338hbmPdKOsU0SZflRr1+yfPkar8COC9VEm1qyBWxmfxpbuyhAytp0P2OIa5qUzeWGQ4PvkA==} engines: {node: '>= 20'} @@ -3178,12 +3080,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ai@7.0.154: - resolution: {integrity: sha512-vpvOZoV2Xcu6vWYM/X2XAFJG93T/ELF+qbslXxRnpqqhKZ6dtxfZf1KHFqYBz784uV/XVjXkNjgs9Zc239NY3Q==} - engines: {node: '>=22'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - ai@7.0.156: resolution: {integrity: sha512-qtXze+Go5L2K4nOhrrg75ETZN++FVB2dWipm0bGo1bCh4JtMZ7xD6zSAZNSv/94e3Ks4LUZ5IWoBOIa+KHiIng==} engines: {node: '>=22'} @@ -3679,21 +3575,37 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - eve@0.82.0: - resolution: {integrity: sha512-HQqjGebljxuO0EYX0XkQaY3/lHmn/oaFe8ggPAkcNIgVW9elyH7x+NjhABAqCO+Tz4azhurq87KeZzbRphJosA==} + eve@0.85.0: + resolution: {integrity: sha512-34rSRCGUlM7crePe22f361mVdic4ktFCrAJsFfWSjlgsDn3NZhjqcRnaM/SS6wUMtdALPpkgwBTdTtDCUJC0rg==} engines: {node: '>=24'} hasBin: true peerDependencies: + '@linqapp/sdk': ^0.47.1 + '@nuxt/kit': ^4.0.0 + '@nuxt/schema': ^4.0.0 '@opentelemetry/api': ^1.0.0 + '@vercel/connect': ^2.5.2 ai: ^7.0.137 braintrust: ^3.0.0 chat: ^4.42.0 dd-trace: ^6.13.0 - just-bash: ^3.1.0 - microsandbox: ^0.5.0 + just-bash: ^3.6.0 + next: ^16.0.0 + react: ^19.0.0 + svelte: ^5.0.0 + vite: ^8.3.1 + vue: ^3.5.0 peerDependenciesMeta: + '@linqapp/sdk': + optional: true + '@nuxt/kit': + optional: true + '@nuxt/schema': + optional: true '@opentelemetry/api': optional: true + '@vercel/connect': + optional: true braintrust: optional: true chat: @@ -3702,11 +3614,19 @@ packages: optional: true just-bash: optional: true - microsandbox: + next: + optional: true + react: + optional: true + svelte: + optional: true + vite: + optional: true + vue: optional: true - eve@0.85.0: - resolution: {integrity: sha512-34rSRCGUlM7crePe22f361mVdic4ktFCrAJsFfWSjlgsDn3NZhjqcRnaM/SS6wUMtdALPpkgwBTdTtDCUJC0rg==} + eve@0.87.0: + resolution: {integrity: sha512-ww3ki9xFLzSOXgBPBCrXiEqXyNKQgUUDpEizoHThtcqVpn65EZhiv0o5391MEzuVVJmugA0YYdUV4mE+SpCJ7Q==} engines: {node: '>=24'} hasBin: true peerDependencies: @@ -4514,27 +4434,6 @@ packages: resolution: {integrity: sha512-iGBUfFB7yPczHHtA8dksKTJ9E8TESNTAx1UQWW6TzMF280vo9jdPYpLUXrMN1BCkPdHFdNG3fxOt2CUad8KhAw==} engines: {node: '>=18'} - next@16.2.6: - resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} - engines: {node: '>=20.9.0'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - next@16.2.9: resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} engines: {node: '>=20.9.0'} @@ -5427,13 +5326,6 @@ snapshots: optionalDependencies: zod: 4.4.3 - '@ai-sdk/gateway@4.0.127(zod@4.4.3)': - dependencies: - '@ai-sdk/provider': 4.0.34 - '@ai-sdk/provider-utils': 5.0.69(zod@4.4.3) - '@vercel/oidc': 3.2.0 - zod: 4.4.3 - '@ai-sdk/gateway@4.0.129(zod@4.4.3)': dependencies: '@ai-sdk/provider': 4.0.34 @@ -5464,15 +5356,6 @@ snapshots: undici: 7.29.1 zod: 4.4.3 - '@ai-sdk/provider-utils@5.0.69(zod@4.4.3)': - dependencies: - '@ai-sdk/provider': 4.0.34 - '@standard-schema/spec': 1.1.0 - '@workflow/serde': 4.1.0 - eventsource-parser: 3.1.1 - undici: 7.29.1 - zod: 4.4.3 - '@ai-sdk/provider-utils@5.0.70(zod@4.4.3)': dependencies: '@ai-sdk/provider': 4.0.34 @@ -6034,55 +5917,29 @@ snapshots: '@modelcontextprotocol/core': 2.3.1 zod: 4.4.3 - '@next/env@16.2.6': {} - '@next/env@16.2.9': {} - '@next/swc-darwin-arm64@16.2.6': - optional: true - '@next/swc-darwin-arm64@16.2.9': optional: true - '@next/swc-darwin-x64@16.2.6': - optional: true - '@next/swc-darwin-x64@16.2.9': optional: true - '@next/swc-linux-arm64-gnu@16.2.6': - optional: true - '@next/swc-linux-arm64-gnu@16.2.9': optional: true - '@next/swc-linux-arm64-musl@16.2.6': - optional: true - '@next/swc-linux-arm64-musl@16.2.9': optional: true - '@next/swc-linux-x64-gnu@16.2.6': - optional: true - '@next/swc-linux-x64-gnu@16.2.9': optional: true - '@next/swc-linux-x64-musl@16.2.6': - optional: true - '@next/swc-linux-x64-musl@16.2.9': optional: true - '@next/swc-win32-arm64-msvc@16.2.6': - optional: true - '@next/swc-win32-arm64-msvc@16.2.9': optional: true - '@next/swc-win32-x64-msvc@16.2.6': - optional: true - '@next/swc-win32-x64-msvc@16.2.9': optional: true @@ -7769,48 +7626,33 @@ snapshots: '@upstash/qstash': 2.12.0 zod: 4.4.3 - '@vercel/cli-config@0.2.0': - dependencies: - xdg-app-paths: 5.5.1 - zod: 4.1.11 - '@vercel/cli-config@0.3.4': dependencies: xdg-app-paths: 5.5.1 zod: 4.1.11 - '@vercel/cli-exec@0.1.1': - dependencies: - execa: 5.1.1 - '@vercel/cli-exec@1.0.1': dependencies: execa: 5.1.1 - '@vercel/connect@0.2.2(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.154(zod@4.4.3))(eve@0.82.0(@opentelemetry/api@1.9.1)(ai@7.0.154(zod@4.4.3)))': + '@vercel/connect@2.5.6(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.156(zod@4.4.3))(eve@0.85.0)': dependencies: - '@vercel/oidc': 3.6.1 + '@vercel/oidc': 4.0.4 optionalDependencies: '@ai-sdk/mcp': 2.0.86(zod@4.4.3) - ai: 7.0.154(zod@4.4.3) - eve: 0.82.0(@opentelemetry/api@1.9.1)(ai@7.0.154(zod@4.4.3)) + ai: 7.0.156(zod@4.4.3) + eve: 0.85.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.156(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) - '@vercel/connect@2.5.6(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.156(zod@4.4.3))(eve@0.85.0)': + '@vercel/connect@2.5.6(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.156(zod@4.4.3))(eve@0.87.0)': dependencies: '@vercel/oidc': 4.0.4 optionalDependencies: '@ai-sdk/mcp': 2.0.86(zod@4.4.3) ai: 7.0.156(zod@4.4.3) - eve: 0.85.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.156(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) + eve: 0.87.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.156(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)) '@vercel/oidc@3.2.0': {} - '@vercel/oidc@3.6.1': - dependencies: - '@vercel/cli-config': 0.2.0 - '@vercel/cli-exec': 0.1.1 - jose: 5.10.0 - '@vercel/oidc@4.0.4': dependencies: '@vercel/cli-config': 0.3.4 @@ -7865,13 +7707,6 @@ snapshots: acorn@8.17.0: {} - ai@7.0.154(zod@4.4.3): - dependencies: - '@ai-sdk/gateway': 4.0.127(zod@4.4.3) - '@ai-sdk/provider': 4.0.34 - '@ai-sdk/provider-utils': 5.0.69(zod@4.4.3) - zod: 4.4.3 - ai@7.0.156(zod@4.4.3): dependencies: '@ai-sdk/gateway': 4.0.129(zod@4.4.3) @@ -8427,22 +8262,26 @@ snapshots: esutils@2.0.3: {} - eve@0.82.0(@opentelemetry/api@1.9.1)(ai@7.0.154(zod@4.4.3)): + eve@0.85.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.156(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)): dependencies: - ai: 7.0.154(zod@4.4.3) + ai: 7.0.156(zod@4.4.3) nitro: 3.0.260903-beta undici: 8.10.2 optionalDependencies: '@opentelemetry/api': 1.9.1 + '@vercel/connect': 2.5.6(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.156(zod@4.4.3))(eve@0.85.0) + next: 16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + vite: 5.4.21(@types/node@20.19.43)(lightningcss@1.32.0) - eve@0.85.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.156(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)): + eve@0.87.0(@opentelemetry/api@1.9.1)(@vercel/connect@2.5.6)(ai@7.0.156(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vite@5.4.21(@types/node@20.19.43)(lightningcss@1.32.0)): dependencies: ai: 7.0.156(zod@4.4.3) nitro: 3.0.260903-beta undici: 8.10.2 optionalDependencies: '@opentelemetry/api': 1.9.1 - '@vercel/connect': 2.5.6(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.156(zod@4.4.3))(eve@0.85.0) + '@vercel/connect': 2.5.6(@ai-sdk/mcp@2.0.86(zod@4.4.3))(ai@7.0.156(zod@4.4.3))(eve@0.87.0) next: 16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 vite: 5.4.21(@types/node@20.19.43)(lightningcss@1.32.0) @@ -9447,31 +9286,6 @@ snapshots: neverthrow@7.2.0: {} - next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@next/env': 16.2.6 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.38 - caniuse-lite: 1.0.30001799 - postcss: 8.4.31 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-jsx: 5.1.6(react@19.2.6) - optionalDependencies: - '@next/swc-darwin-arm64': 16.2.6 - '@next/swc-darwin-x64': 16.2.6 - '@next/swc-linux-arm64-gnu': 16.2.6 - '@next/swc-linux-arm64-musl': 16.2.6 - '@next/swc-linux-x64-gnu': 16.2.6 - '@next/swc-linux-x64-musl': 16.2.6 - '@next/swc-win32-arm64-msvc': 16.2.6 - '@next/swc-win32-x64-msvc': 16.2.6 - '@opentelemetry/api': 1.9.1 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@next/env': 16.2.9 From 035b49a2630216f587fe3a349f986ed5a2cf9933 Mon Sep 17 00:00:00 2001 From: Upstash Box Date: Thu, 29 Oct 2026 09:29:26 +0000 Subject: [PATCH 3/3] chore: revert generated next-env.d.ts change Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CMK4etZKNEc4Rzig1SCPpJ --- examples/mcp-toolkit-demo/next-env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/mcp-toolkit-demo/next-env.d.ts b/examples/mcp-toolkit-demo/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/examples/mcp-toolkit-demo/next-env.d.ts +++ b/examples/mcp-toolkit-demo/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.