Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/eve-045-upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@upstash/agentkit-eve-extension": minor
---

fix!: rebuild against eve 0.45 and raise the `eve` peer floor to `>=0.45.0`

`eve extension build` stamps the compatibility manifest with the *building* eve's
current contribution-format versions, and a consumer rejects any version missing
from its own supported list. Rebuilding on **eve 0.45.0** re-stamps
`dist/extension/_manifest.json` from tool 17 / dynamicTool 18 to
**tool 18 / dynamicTool 19** (hook 14 and instructions 2 are unchanged), and
eve 0.45.0 is the first release supporting either — every 0.42–0.44.4 tops out at
tool 17 / dynamicTool 18.

- The extension is now built with **eve 0.45.0** (manifest: formatVersion 2,
tool 18 / dynamicTool 19 / hook 14 / instructions 2) and loads on **eve ≥ 0.45.0**.
- The `eve` peer dependency moves from `">=0.43.0"` to `">=0.45.0"` so an
incompatible eve fails at install time rather than at `eve build`, where it
otherwise errors with *"requires tool contract v18, but this eve supports tool
contract versions: … v17"*.

`@upstash/agentkit-eve`'s `eve` peer stays `>=0.32.0` — its source still
typechecks cleanly against eve 0.32.0, so no floor change is warranted there.

No runtime behavior and no source changed: all packages build, typecheck and pass
unmodified against eve 0.45.0, both demos build, and the extension's mocked-model
end-to-end eval is green. eve 0.45.0 removed `eve/tools/defaults` and the
`defineBashTool`/`defineReadFileTool`/`defineWriteFileTool`/`defineGlobTool`/
`defineGrepTool` factories, and dropped `experimental.subagentPersistentSessions`
— AgentKit uses none of them. `ai` stays exact-pinned at `7.0.58` (eve 0.45's peer
is still `^7.0.58`).
61 changes: 51 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension).
has `"eve": { "extension": { "source": "./extension", "dist": "./dist/extension" } }`, `files` ships
**`dist/` only** (compiled `.mjs` + `.d.ts` per contribution, plus `_manifest.json` with
`builtWithEve`; eve validates compatibility from the manifest), and `eve` is a **floored peer**
(`">=0.43.0"` — was the scaffold's `"*"` until issue #22; see **Consumer eve version** below). The old 0.24
(`">=0.45.0"` — was the scaffold's `"*"` until issue #22; see **Consumer eve version** below). The old 0.24
format (`"eve": { "extension": "./extension" }`, ships source the consumer recompiles) is rejected by
eve ≥0.25 with "must declare `eve.extension.dist`" — don't regress to it. **No `prepare` script** (an
install-time build broke CI: sdk isn't built yet at install; `pnpm build` handles topological order).
Expand All @@ -111,9 +111,10 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension).
npm/yarn hoisted layouts — was fixed upstream in eve 0.25.3; no workaround needed on ≥0.25.3.)
**Consumer eve version:** `eve extension build` stamps the manifest's `requires` with the building
eve's *current* contribution-format versions, and a consumer rejects any version not in its own
supported list — a dist built with eve 0.44.3 (formatVersion 2; tool 17 / dynamicTool 18 / hook 14 /
instructions 2) needs consumers on **eve ≥0.43** (tool 17 is the binding contract; hook 14 landed in
0.40). The `eve` peer is **`">=0.43.0"`, not `"*"`** — issue #22 proved the wildcard is a trap: eve
supported list — a dist built with eve 0.45.0 (formatVersion 2; tool 18 / dynamicTool 19 / hook 14 /
instructions 2) needs consumers on **eve ≥0.45** (tool 18 *and* dynamicTool 19 are both binding —
0.45 is the first eve supporting either; every 0.42–0.44.4 tops out at tool 17 / dynamicTool 18).
The `eve` peer is **`">=0.45.0"`, not `"*"`** — issue #22 proved the wildcard is a trap: eve
0.33 dropped hook contracts ≤9 *nine hours* after 0.32 shipped, so a wildcard install succeeds and
then fails at `eve build` with a manifest error. The manifest is still the real compatibility tie;
the peer floor is the install-time guard. **On every eve devDep bump: rebuild, read the new
Expand Down Expand Up @@ -227,7 +228,7 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension).
`new Ratelimit()`.

## AI SDK version strategy — IMPORTANT
- **AI SDK v7 stable everywhere.** Every package + demo pins `ai` to exactly **`7.0.58`**. `eve` (0.44)
- **AI SDK v7 stable everywhere.** Every package + demo pins `ai` to exactly **`7.0.58`**. `eve` (0.45)
declares `ai` as a **peer** (`^7.0.58`), so the apps/packages provide the single copy. Providers:
`@ai-sdk/openai` `^4.0.37`, `@ai-sdk/provider` `^4.0.7`, `@ai-sdk/react` `^4.0.62` (all stable ranges;
bump them with `pnpm -r update "@ai-sdk/*"` when eve moves — a stale `@ai-sdk/react` range can pin a
Expand Down Expand Up @@ -258,8 +259,33 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension).
- vitest: `fileParallelism: false`, `testTimeout: 30_000`.
- **Upstash DB caps at 10 search indexes** (`ERR Exceeded max index count of 10`). Tests must `drop()` /
reuse indexes and run sequentially. There is **no** `SEARCH.LIST` command to enumerate them.
- **Indexing is async.** After writing (`redis.json.set`), call `searchIndex.waitIndexing()` before
reading/asserting. Demos do the same.
- **Indexing is async, and visibility needs BOTH halves of a rule.** To assert on a search result you
must satisfy *both*, or the docs may never become visible — not "late", **never**:
1. **The index must already exist when the doc is written.** A doc written while the index is still
missing can be dropped by the create-time backfill permanently. Anything relying on the reactive
create (`ReactiveSearchIndex` provisioning on the first read) to pick up docs seeded *just*
beforehand is a coin flip.
2. **`searchIndex.waitIndexing()` must be called after the write**, on that already-existing index.

Measured against live Redis with a 3-way probe — `provision-then-write-then-wait` returned the full
result set on the *first* read (0ms); `provision-only` (no post-write wait) and `wait-only` (index
created after the writes) both sat at 0 hits for the full 10s probe and never recovered. Waiting
longer does not help: a 25s poll on the old ordering still failed. So order is
**provision → write → `waitIndexing()` → read**, and `waitIndexing()` on an index that doesn't
exist yet is a silent no-op, which is the trap.
- **This was the cause of the long-running `Test` flake, and it is fixed.** `packages/sdk/src/
chat-history.test.ts` ("lists a user's chats" → `expected [ 'c1' ] to include 'c2'`) and
`packages/eve/src/search-tools.test.ts` ("search runs a $smart query…" → `expected 0 to be greater
than 0` / `expected 1 to be greater than or equal to 2`) red-lit CI repeatedly, including the
2026-08-26 nightly on `main` (eve 0.44.3) and three runs of PR #27. Both now provision the index
before seeding and keep a small `pollUntil` helper as insurance for residual lag; assertions are
unchanged. **10/10 consecutive green each** after the fix (was 4/10 and 4/10). If either goes red
again, treat it as a real regression, not noise.
- **Throwaway DBs from `upstash start-redis` (the `@upstash/cli` command; `npm i -g @upstash/cli`)
cap at *one* search index**, not 10 — `ERR Exceeded max index count of 1`. A single `pnpm test`
cascades into bogus create-index failures on one. Run **one test file at a time** with a `FLUSHDB`
between (`curl "$URL" -H "Authorization: Bearer $TOKEN" -d '["FLUSHDB"]'`; FLUSHDB does drop
indexes, and `SEARCH.DROP <name>` is the only other lever — there is no list command).
- Scores are **BM25 (unbounded)**, not `[0,1]` — `minScore` thresholds are BM25 values.
- `.env` is gitignored — **never commit creds.** Needs `UPSTASH_REDIS_REST_URL`/`_TOKEN`; optionally
`OPENAI_API_KEY` and `UPSTASH_BOX_API_KEY`.
Expand All @@ -277,9 +303,10 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension).
`$count`, `$histogram`, `$percentiles`, `$cardinality`.

## Eve framework facts
- The repo is on **`eve@0.44.3`** (peer `>=0.32.0` in `packages/eve` — 0.32 is where the sandbox
`stop()` contract our backend implements landed; peer `>=0.43.0` in the extension, matching the
built dist's manifest — see the eve-extension section). Subpath exports:
- The repo is on **`eve@0.45.0`** (peer `>=0.32.0` in `packages/eve` — 0.32 is where the sandbox
`stop()` contract our backend implements landed, re-verified by typechecking `packages/eve` against
eve 0.32.0; peer `>=0.45.0` in the extension, matching the built dist's manifest — see the
eve-extension section). Subpath exports:
`eve/tools`, `eve/hooks`, `eve/extension`, `eve/context`, `eve/instructions`, `eve/sandbox`,
`eve/sandbox/vercel`, `eve/channels/*`, `eve/next`, `eve/react`, …
- **Breaking changes absorbed on the 0.25 → 0.32 jump:** (a) 0.31 replaced continuation-token session
Expand Down Expand Up @@ -310,6 +337,20 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension).
count matching seed data, cached weather tool, and the extension's dynamic search + chat-history
hook/tools writing and reading real `agentkit:chat:demo-user:*` docs); `gpt-5.4-mini` does exist
and responds — the "may 404" caveat in Known issues didn't materialize.
- **The 0.44.3 → 0.45.0 bump also needed no source changes** — build, typecheck, both demo builds and
the mocked-model eval all passed unmodified. It was, again, *purely* a manifest/peer-floor move:
rebuilding on 0.45.0 re-stamps **tool 17→18** and **dynamicTool 18→19**, and 0.45.0 is the first eve
supporting either, so the extension peer floor went `>=0.43.0` → **`>=0.45.0`** (a 0.45-built dist
hard-errors at discovery on 0.44.3: *"requires tool contract v18, but this eve supports … v17"*).
Note the floor is now equal to the pinned version — 0.45 raised both contracts it stamps, so there
is no back-compat window at all this time. Why 0.45's headline breaks missed us: built-in tool
definitions moved from `eve/tools/defaults` to per-tool `eve/tools/<name>` subpaths and
`defineBashTool`/`defineReadFileTool`/`defineWriteFileTool`/`defineGlobTool`/`defineGrepTool` were
**removed** — we import only `defineTool`/`defineDynamic`/`SessionContext`/`ToolContext`/
`ToolDefinition` from `eve/tools`, never `eve/tools/defaults` nor any of those factories; and
`experimental.subagentPersistentSessions` was removed (subagent contract 1 and 2 are now *dropped*,
only 3 is supported) — we contribute no subagents. `ai` stays exact-pinned at `7.0.58` (eve 0.45's
peer is still `^7.0.58`).
- **Extension packaging changed 0.24 → 0.25**: 0.24 shipped source the consumer recompiles; 0.25 ships
prebuilt `dist/extension` + `_manifest.json` (see the eve-extension section). 0.25 rejects
0.24-format packages at discovery.
Expand Down
2 changes: 1 addition & 1 deletion examples/eve-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"eve": "^0.44.3",
"eve": "^0.45.0",
"lucide-react": "1.16.0",
"motion": "12.40.0",
"nanoid": "5.1.11",
Expand Down
2 changes: 1 addition & 1 deletion examples/eve-extension-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"@upstash/redis": "^1.38.0",
"@vercel/connect": "0.2.2",
"ai": "7.0.58",
"eve": "^0.44.3",
"eve": "^0.45.0",
"zod": "4.4.3"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/eve-extension/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ unavailable, use https://eve.dev/docs/extensions as a fallback.
agent-shaped source tree into `dist/extension/`, emits type declarations and a
compatibility manifest, and fills the package `exports` map. Ship `dist/` only.
`eve` is a required peer so the consumer's eve is the one that runs, but NOT a
wildcard: keep the floor (`>=0.43.0`) in sync with what the built manifest's
wildcard: keep the floor (`>=0.45.0`) in sync with what the built manifest's
contracts require, so an incompatible eve fails at install instead of at
`eve build` (see issue #22). eve validates the real compatibility from the
generated manifest.
2 changes: 1 addition & 1 deletion packages/eve-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ no repeated schemas; upgrades come through the package manager.

`<ns>` is the mount file's basename — the examples below use `agentkit`.

Start from an eve project (eve ≥ 0.43 — the prebuilt extension's compatibility manifest requires 0.43's contribution formats; the package declares this as its `eve` peer range), then:
Start from an eve project (eve ≥ 0.45 — the prebuilt extension's compatibility manifest requires 0.45's contribution formats; the package declares this as its `eve` peer range), then:

```bash
pnpm add @upstash/agentkit-eve-extension
Expand Down
4 changes: 2 additions & 2 deletions packages/eve-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@
},
"devDependencies": {
"@types/node": "24.x",
"eve": "^0.44.3",
"eve": "^0.45.0",
"typescript": "7.0.2"
},
"peerDependencies": {
"eve": ">=0.43.0"
"eve": ">=0.45.0"
}
}
2 changes: 1 addition & 1 deletion packages/eve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"@upstash/redis": "^1.38.0",
"ai": "7.0.58",
"dotenv": "^16.4.5",
"eve": "^0.44.3"
"eve": "^0.45.0"
},
"peerDependencies": {
"@upstash/box": ">=0.5.0",
Expand Down
49 changes: 43 additions & 6 deletions packages/eve/src/search-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ function call<R>(execute: unknown, input: unknown): Promise<R> {
return (execute as (i: unknown, c: unknown) => Promise<R>)(input, CTX);
}

/**
* Re-run `read` until `ready` holds (or the deadline passes) and return the last value, so a caller
* asserting on search results doesn't race Upstash's asynchronous indexing. On a genuine breakage
* the predicate never holds and the caller's own `expect` still reports the original failure.
*/
async function pollUntil<R>(read: () => Promise<R>, ready: (value: R) => boolean): Promise<R> {
const deadline = Date.now() + 8_000; // two polled reads still sit inside vitest's 30s testTimeout
let value = await read();
while (!ready(value) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250));
value = await read();
}
return value;
}

const schema = s.object({
name: s.string(),
age: s.number(),
Expand Down Expand Up @@ -35,18 +50,40 @@ describe.skipIf(!hasRedisCreds)("defineSearchTools (live Redis)", () => {
});

it("search runs a $smart query, creating the index reactively", async () => {
// This first call is the reactive create (the index does not exist yet) — the behaviour this
// test is named for. It has to happen BEFORE the writes: a doc written while the index is still
// missing can be dropped by the create-time backfill *permanently*, not just late, so seeding
// first and letting the read provision made this assertion flaky. Writes into an index that
// already exists are indexed incrementally.
await call<{ count: number }>(tools.count.execute, { filter: { city: { $eq: "nowhere" } } });

await redis.json.set(`${prefix}1`, "$", { name: "Ada Lovelace", age: 36, city: "London" });
await redis.json.set(`${prefix}2`, "$", { name: "Alan Turing", age: 41, city: "London" });

const hits = await call<{ data?: { name?: string } }[]>(tools.search.execute, {
filter: { name: { $smart: "ada" } },
});
// ...and the seeded docs only become visible once indexing is flushed. Verified against live
// Redis: provisioning first *or* waiting alone leaves both reads at 0 indefinitely; only the
// pair makes them land. The poll below is the belt-and-braces for residual lag.
await redis.search.index({ name, schema }).waitIndexing();

// Both reads poll until the index has caught up with the two writes above; the assertions
// themselves are unchanged.
const hits = await pollUntil(
() =>
call<{ data?: { name?: string } }[]>(tools.search.execute, {
filter: { name: { $smart: "ada" } },
}),
(found) => found.some((h) => h.data?.name?.includes("Ada")),
);
expect(hits.length).toBeGreaterThan(0);
expect(hits.some((h) => h.data?.name?.includes("Ada"))).toBe(true);

const result = await call<{ count: number }>(tools.count.execute, {
filter: { city: { $eq: "London" } },
});
const result = await pollUntil(
() =>
call<{ count: number }>(tools.count.execute, {
filter: { city: { $eq: "London" } },
}),
(counted) => counted.count >= 2,
);
expect(result.count).toBeGreaterThanOrEqual(2);
});
});
33 changes: 32 additions & 1 deletion packages/sdk/src/chat-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ const msg = (id: string, role: Msg["role"], text: string): Msg => ({
parts: [{ type: "text", text }],
});

/**
* Re-run `read` until `ready` holds (or the deadline passes) and return the last value, so a caller
* asserting on search results doesn't race Upstash's asynchronous indexing. `waitIndexing()` is not
* a hard barrier for a doc written moments earlier. On a genuine breakage the predicate never holds
* and the caller's own `expect` still reports the original failure.
*/
async function pollUntil<T>(read: () => Promise<T>, ready: (value: T) => boolean): Promise<T> {
const deadline = Date.now() + 8_000; // well inside vitest's 30s testTimeout
let value = await read();
while (!ready(value) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250));
value = await read();
}
return value;
}

describe.skipIf(!hasRedisCreds)("ChatHistory (live Redis Search)", () => {
const redis = testRedis();
const history = new ChatHistory<Msg>({ redis, prefix: uniquePrefix("chat") });
Expand Down Expand Up @@ -110,6 +126,13 @@ describe.skipIf(!hasRedisCreds)("ChatHistory (live Redis Search)", () => {
});

it("lists a user's chats (filtered by userId in the index)", async () => {
// The index is created lazily by the first search read. A doc written while it does not exist
// yet can be missed by the create-time backfill *permanently* — verified against live Redis:
// c2 never became visible, even after 25s of polling. So force the index to exist first (this
// read is itself the reactive create); writes after that are indexed incrementally. c1, written
// several tests ago, has long settled and is picked up by the backfill.
await history.listChats({ userId: "nobody" });

await history.saveChat({
userId: user,
sessionId: "c2",
Expand All @@ -118,7 +141,15 @@ describe.skipIf(!hasRedisCreds)("ChatHistory (live Redis Search)", () => {
});
await history.searchIndex.waitIndexing();

const list = await history.listChats({ userId: user });
// ...and `waitIndexing()` still isn't a hard barrier, so read until both chats are visible
// rather than asserting on the first response. The assertions below are unchanged.
const list = await pollUntil(
() => history.listChats({ userId: user }),
(chats) => {
const seen = chats.map((c) => c.sessionId);
return seen.includes("c1") && seen.includes("c2");
},
);
const ids = list.map((c) => c.sessionId);
expect(ids).toContain("c1");
expect(ids).toContain("c2");
Expand Down
Loading
Loading