Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/file-memory-vercel-blob.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Add a scope-neutral `fileMemory()` provider with indexed save and forget tools, a configurable 100-memory default limit, a portable versioned-document backend, process-local development storage, and private Vercel Blob persistence selected automatically on Vercel.
49 changes: 43 additions & 6 deletions docs/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ The two forms are mutually exclusive. Local subagents can declare their own slot

```ts title="agent/memory/user.ts"
import { byPrincipal, defineMemory } from "eve/memory";
import { userMemory } from "../lib/user-memory";
import { fileMemory } from "eve/memory/file";

export default defineMemory({
provider: userMemory,
provider: fileMemory(),
scope: byPrincipal(),
});
```
Expand All @@ -41,15 +41,52 @@ scope(ctx) {

eve hashes the application, environment, graph node, slot, and tuple into `ctx.memory.scope.key`. The original tuple remains available as `ctx.memory.scope.parts`. Scope is resolved once and locked through the turn; the model never supplies either value.

## File memory

`fileMemory()` is eve's bounded, model-maintained memory file. It works with any scope; `byPrincipal()` in the example above creates a separate file for each authenticated principal. The provider recalls indexed memories at the start of each turn and exposes two minimal operations: `save_memory(text)` adds one memory and returns its index, while `forget_memory(index)` removes one memory. eve qualifies them as `user__save_memory` and `user__forget_memory` because the slot file is `user.ts`.

The stored file contains one memory per line, so the recalled context gives the model the index needed to forget an entry without recreating the rest:

```text
0: Prefers dark mode.
1: Likes concise answers.
```

eve allocates each new memory one index above the current highest index and never rewrites the indexes of surviving memories. It also normalizes saved text to one line, preserves unrelated memories, and retries conditional writes when another invocation changes the document concurrently. Saving identical text returns its existing index instead of adding a duplicate. Forgetting an index that no longer exists is a no-op.

The provider stores at most 100 memories by default. Configure `memoryLimit` to change that count. At the limit, a failed save tells the model to forget an outdated memory by index and retry. The provider tells the model to keep stable context and omit secrets, instructions, and current-task details. It does not run a hidden capture model or persist complete transcripts.

On Vercel, the default backend stores private `MEMORY.md` objects in Vercel Blob. Attach a Blob store to the project so `BLOB_STORE_ID` and Vercel OIDC are available, or provide `BLOB_READ_WRITE_TOKEN`. Outside Vercel, the default is process-local memory for zero-configuration development and tests; it is not durable across restarts.

Pin Vercel Blob explicitly when you want to use it locally or customize credentials and pathnames:

```ts title="agent/memory/user.ts"
import { byPrincipal, defineMemory } from "eve/memory";
import { fileMemory, vercelBlob } from "eve/memory/file";

export default defineMemory({
provider: fileMemory({
backend: vercelBlob({
prefix: "my-agent/memory/files",
token: process.env.BLOB_READ_WRITE_TOKEN,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why do we need this token? Let's recommend OIDC

}),
memoryLimit: 200,
}),
scope: byPrincipal(),
});
```

Storage is deliberately behind `MemoryDocumentBackend`, a conditional read/replace contract for one versioned text document. A KV store, database row, S3 object, or R2 object can implement that contract without becoming part of eve's memory model. Stale writes must throw `MemoryDocumentConflictError`; the provider rereads the latest document and reapplies the individual save or forget operation.

## Define a provider

Providers opt into only the lifecycle points they need:

```ts title="agent/lib/user-memory.ts"
```ts title="agent/lib/custom-memory.ts"
import { defineMemoryProvider } from "eve/memory";
import { service } from "./service";

export const userMemory = defineMemoryProvider({
export const customMemory = defineMemoryProvider({
events: {
async "turn.prepared"(_event, ctx) {
const context = await service.recall(ctx.memory.scope, ctx.messages);
Expand All @@ -72,7 +109,7 @@ A provider can expose scoped tools for the whole turn and replace or clear them
import { defineTool } from "eve/tools";
import { z } from "zod";

export const userMemory = defineMemoryProvider({
export const customMemory = defineMemoryProvider({
tools: {
"turn.prepared"(_event, ctx) {
const scope = ctx.memory.scope;
Expand Down Expand Up @@ -106,4 +143,4 @@ Completed-turn handlers do not run for failed, cancelled, adapter-consumed, or i

## Testing providers

Tests can use a module-local `Map` keyed by `ctx.memory.scope.key` to exercise recall and capture without a service. Treat that only as process-local test storage: it is neither durable nor shared across serverless instances. eve intentionally ships no storage provider as part of the framework contract.
Use `inMemory()` from `eve/memory/file` when testing `fileMemory()` or a custom `MemoryDocumentBackend`. Treat it only as process-local storage: it is neither durable nor shared across serverless instances.
1 change: 1 addition & 0 deletions docs/reference/typescript-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ A few non-`define*` helpers round out the set: `disableTool`, `experimental_work
| `eve/channels/{slack,discord,teams,telegram,twilio,github}` | platform channel factories |
| `eve/hooks` | `defineHook` |
| `eve/memory` | `defineMemory`, `defineMemoryProvider`, `byPrincipal` |
| `eve/memory/file` | `fileMemory`, `inMemory`, `vercelBlob`, `MemoryDocumentBackend` |
| `eve/schedules` | `defineSchedule` |
| `eve/skills` | `defineSkill`, `defineDynamic` |
| `eve/instructions` | `defineInstructions`, `defineDynamic` |
Expand Down
11 changes: 11 additions & 0 deletions packages/eve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,16 @@
"import": "./dist/src/public/memory/index.js",
"default": "./dist/src/public/memory/index.js"
},
"./memory/file": {
"types": "./dist/src/public/memory/file/index.d.ts",
"import": "./dist/src/public/memory/file/index.js",
"default": "./dist/src/public/memory/file/index.js"
},
"./memory/file/vercel": {
"types": "./dist/src/public/memory/file/vercel.d.ts",
"import": "./dist/src/public/memory/file/vercel.js",
"default": "./dist/src/public/memory/file/vercel.js"
},
"./sandbox": {
"types": "./dist/src/public/sandbox/index.d.ts",
"import": "./dist/src/public/sandbox/index.js",
Expand Down Expand Up @@ -341,6 +351,7 @@
"@types/json-schema": "7.0.15",
"@types/react": "catalog:",
"@types/react-test-renderer": "19.1.0",
"@vercel/blob": "2.4.0",
"@vercel/detect-agent": "1.2.3",
"@vercel/oidc": "3.8.0",
"@vercel/otel": "catalog:",
Expand Down
15 changes: 15 additions & 0 deletions packages/eve/scripts/vendor-compiled/@vercel/blob.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { loadDeclaration } from "../_shared.mjs";

/** Vendored server-side Vercel Blob slice used by the file-memory backend. */
export default {
packageName: "@vercel/blob",
compiledPath: "@vercel/blob",
bundling: "standalone",
entries: [
{
entry: "dist/index.js",
outputPath: "index",
declaration: await loadDeclaration("@vercel/blob.d.ts"),
},
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export interface BlobCommandOptions {
readonly abortSignal?: AbortSignal;
readonly oidcToken?: string;
readonly storeId?: string;
readonly token?: string;
}

export interface GetCommandOptions extends BlobCommandOptions {
readonly access: "private" | "public";
readonly useCache?: boolean;
}

export interface GetBlobResult {
readonly blob: {
readonly etag: string;
};
readonly statusCode: 200 | 304;
readonly stream: ReadableStream<Uint8Array> | null;
}

export interface PutCommandOptions extends BlobCommandOptions {
readonly access: "private" | "public";
readonly addRandomSuffix?: boolean;
readonly allowOverwrite?: boolean;
readonly cacheControlMaxAge?: number;
readonly contentType?: string;
readonly ifMatch?: string;
}

export interface PutBlobResult {
readonly etag: string;
}

export declare class BlobPreconditionFailedError extends Error {}

export declare function get(
pathname: string,
options: GetCommandOptions,
): Promise<GetBlobResult | null>;

export declare function put(
pathname: string,
body: string,
options: PutCommandOptions,
): Promise<PutBlobResult>;
2 changes: 2 additions & 0 deletions packages/eve/scripts/vendor-compiled/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import photonChatAdapterIMessage from "./@photon-ai/chat-adapter-imessage.mjs";
import opentelemetryApi from "./@opentelemetry/api.mjs";
import opentelemetryOtlpTransformer from "./@opentelemetry/otlp-transformer.mjs";
import standardSchemaSpec from "./@standard-schema/spec.mjs";
import vercelBlob from "./@vercel/blob.mjs";
import vercelDetectAgent from "./@vercel/detect-agent.mjs";
import vercelOidc from "./@vercel/oidc.mjs";
import vercelOtel from "./@vercel/otel.mjs";
Expand Down Expand Up @@ -80,6 +81,7 @@ export const MODULES = [
shadcnRegistry,
standardSchemaSpec,
turndown,
vercelBlob,
vercelDetectAgent,
vercelOidc,
vercelOtel,
Expand Down
87 changes: 87 additions & 0 deletions packages/eve/src/execution/file-memory.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";

import { workflowEntry } from "#execution/workflow-entry.js";
import { createTestRuntime } from "#internal/testing/app-harness.js";
import { captureTurnEvents, filterEventsByType } from "#internal/testing/events.js";
import { start } from "#internal/workflow/runtime.js";
import { defineMemory } from "#public/memory/index.js";
import { inMemory, fileMemory } from "#public/memory/file/index.js";
import { createBundledRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js";

describe("file memory integration", () => {
it("saves, recalls, and isolates indexed memories across scopes", async () => {
const backend = inMemory();
const runtime = createTestRuntime({
agent: { name: "file-memory-integration" },
memories: [
{
definition: defineMemory({
provider: fileMemory({ backend }),
scope: (context) => [context.session.auth.current!.principalId],
}),
slot: "facts",
},
],
});

await runtime.run(async () => {
const first = await runTurn({
message: "Call facts__save_memory with one concise memory.",
principalId: "user-1",
});
const recalled = await runTurn({
message: "Show the persistent context you received.",
principalId: "user-1",
});
const isolated = await runTurn({
message: "Show the persistent context you received.",
principalId: "user-2",
});
expect(
first.some(
(event) =>
event.type === "actions.requested" &&
event.data.actions.some(
(action) => action.kind === "tool-call" && action.toolName === "facts__save_memory",
),
),
).toBe(true);
const recalledMessage = filterEventsByType(recalled, "message.completed").at(-1)?.data
.message;
const isolatedMessage = filterEventsByType(isolated, "message.completed").at(-1)?.data
.message;
expect(recalledMessage).toContain("# Persistent memories");
expect(recalledMessage).toContain("0: structured-output");
expect(isolatedMessage).not.toContain("# Persistent memories");
expect(isolatedMessage).not.toContain("structured-output");
});
});
});

async function runTurn(input: { readonly message: string; readonly principalId: string }) {
const run = await start(workflowEntry, [
{
input: { message: input.message },
serializedContext: {
"eve.auth": {
attributes: {},
authenticator: "test",
principalId: input.principalId,
principalType: "user",
},
"eve.bundle": { source: createBundledRuntimeCompiledArtifactsSource() },
"eve.channel": { kind: "http", state: {} },
"eve.continuationToken": `http:file-memory:${input.principalId}:${crypto.randomUUID()}`,
"eve.mode": "conversation",
},
},
]);
const stream = captureTurnEvents(run);

try {
return await stream.nextTurn();
} finally {
stream.dispose();
await run.cancel();
}
}
55 changes: 55 additions & 0 deletions packages/eve/src/public/memory/file/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/** One versioned text document loaded from a memory backend. */
export interface MemoryDocument {
/** Complete UTF-8 document contents. */
readonly content: string;
/** Opaque backend version used for optimistic writes. */
readonly version: string;
}

/** Input shared by document reads. */
export interface MemoryDocumentReadInput {
/** Stable eve scope key for the authored memory slot. */
readonly key: string;
readonly signal: AbortSignal;
}

/** Input for a conditional document replacement. */
export interface MemoryDocumentWriteInput extends MemoryDocumentReadInput {
readonly content: string;
/** Version returned by {@link MemoryDocumentBackend.read}, or `null` for create-only. */
readonly expectedVersion: string | null;
}

/**
* Storage seam for one bounded memory file per eve scope key.
*
* Implementations may map the key to a KV entry, blob object, database row,
* or another durable store. Writes must reject stale `expectedVersion` values
* with {@link MemoryDocumentConflictError}.
*/
export interface MemoryDocumentBackend {
readonly read: (input: MemoryDocumentReadInput) => Promise<MemoryDocument | null>;
readonly write: (input: MemoryDocumentWriteInput) => Promise<MemoryDocument>;
}

/** Raised when a document changed between read and conditional write. */
export class MemoryDocumentConflictError extends Error {
readonly key: string;

constructor(key: string) {
super(`Memory document "${key}" changed before it could be updated.`);
this.name = "MemoryDocumentConflictError";
this.key = key;
}

/** Narrows conflicts across bundle and workflow boundaries. */
static is(error: unknown): error is MemoryDocumentConflictError {
return (
error instanceof MemoryDocumentConflictError ||
(typeof error === "object" &&
error !== null &&
(error as { readonly name?: unknown }).name === "MemoryDocumentConflictError" &&
typeof (error as { readonly key?: unknown }).key === "string")
);
}
}
47 changes: 47 additions & 0 deletions packages/eve/src/public/memory/file/backends/default.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { get, put } from "#compiled/@vercel/blob/index.js";
import { afterEach, describe, expect, it, vi } from "vitest";

import { defaultFileMemoryBackend } from "#public/memory/file/backends/default.js";

vi.mock("#compiled/@vercel/blob/index.js", () => ({
BlobPreconditionFailedError: class BlobPreconditionFailedError extends Error {},
get: vi.fn(),
put: vi.fn(),
}));

const originalVercel = process.env.VERCEL;
const signal = new AbortController().signal;

describe("default file-memory backend", () => {
afterEach(() => {
vi.clearAllMocks();
if (originalVercel === undefined) delete process.env.VERCEL;
else process.env.VERCEL = originalVercel;
});

it("uses process-local storage outside Vercel and caches that selection", async () => {
delete process.env.VERCEL;
const backend = defaultFileMemoryBackend();
await backend.write({ content: "local", expectedVersion: null, key: "mem_a", signal });
process.env.VERCEL = "1";

await expect(backend.read({ key: "mem_a", signal })).resolves.toMatchObject({
content: "local",
});
expect(get).not.toHaveBeenCalled();
expect(put).not.toHaveBeenCalled();
});

it("defers Vercel Blob selection until the first operation", async () => {
delete process.env.VERCEL;
const backend = defaultFileMemoryBackend();
process.env.VERCEL = "1";
vi.mocked(get).mockResolvedValue(null);

await expect(backend.read({ key: "mem_a", signal })).resolves.toBeNull();
expect(get).toHaveBeenCalledWith(
"eve/memory/file/mem_a/MEMORY.md",
expect.objectContaining({ access: "private", useCache: false }),
);
});
});
Loading