diff --git a/.changeset/bright-memories-recall.md b/.changeset/bright-memories-recall.md new file mode 100644 index 0000000000..d09a34fea2 --- /dev/null +++ b/.changeset/bright-memories-recall.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add first-class path-authored memory providers with scoped recall, capture, replayable provider tools, and compaction-safe recalled context. diff --git a/docs/channels/eve.mdx b/docs/channels/eve.mdx index dd67da68ed..e1c336dcf5 100644 --- a/docs/channels/eve.mdx +++ b/docs/channels/eve.mdx @@ -34,7 +34,7 @@ The default eve channel inspects the agent, creates sessions, accepts callbacks The session routes use only durable session IDs. Create a session explicitly, then put its returned ID in every follow-up, control, and stream path. -`GET /eve/v1/health` is public and returns `{ ok: true, status: "ready", workflowId: string }`. `GET /eve/v1/info` uses the channel's auth policy and returns agent-info version 3. The TypeScript client validates both successful payloads: malformed health JSON throws `HealthResponseError`, malformed inspection JSON throws `AgentInfoResponseError`, and a non-success response from either route throws `ClientError`. +`GET /eve/v1/health` is public and returns `{ ok: true, status: "ready", workflowId: string }`. `GET /eve/v1/info` uses the channel's auth policy and returns agent-info version 4. The TypeScript client validates both successful payloads: malformed health JSON throws `HealthResponseError`, malformed inspection JSON throws `AgentInfoResponseError`, and a non-success response from either route throws `ClientError`. ### Start and continue a session diff --git a/docs/concepts/context-control.md b/docs/concepts/context-control.md index f9a47a9014..10c256b448 100644 --- a/docs/concepts/context-control.md +++ b/docs/concepts/context-control.md @@ -16,6 +16,7 @@ Control context by putting information in the narrowest surface that needs it. K | Files or command execution | The [sandbox workspace](../sandbox) | A workspace hint, then files and command output the model requests through tools | | A specialist with a separate prompt and capabilities | A [subagent](../subagents) | Only the subagent's final result in the parent context | | Instructions or capabilities that vary by caller | A [dynamic capability](../guides/dynamic-capabilities) | The values resolved for the active session | +| Scoped context retrieved from cross-session storage | [Memory](../memory) | Attributed user-role messages recalled before the current delivery | ## Base identity with `instructions.md` @@ -59,6 +60,11 @@ See [Dynamic capabilities](../guides/dynamic-capabilities) for the resolver API, User-role instructions follow the normal history lifecycle. Compaction can summarize them, and clear removes them without rerunning their static definitions or dynamic resolvers. System-role instructions remain outside history and continue to apply after either operation. +Recalled memory also uses user-role messages, but eve keeps their attribution +separate. Compaction excludes them from the summary, preserves their canonical +records, and recalls again after the checkpoint. Clear removes those session +records without deleting the provider's external data. + ## What to read next - [Instructions](../instructions): author the always-on system prompt. @@ -66,3 +72,4 @@ User-role instructions follow the normal history lifecycle. Compaction can summa - [Sandbox](../sandbox): give the model files and command execution. - [Subagents](../subagents): isolate specialist work. - [Dynamic capabilities](../guides/dynamic-capabilities): vary context and capabilities by session. +- [Memory](../memory): retrieve scoped context from storage that outlives a session. diff --git a/docs/concepts/default-harness.md b/docs/concepts/default-harness.md index 507294c214..ce5bb6b6bb 100644 --- a/docs/concepts/default-harness.md +++ b/docs/concepts/default-harness.md @@ -20,6 +20,11 @@ export default defineAgent({ Compaction also preserves the framework's own tool state automatically. It resets read-before-write tracking (so a write afterward re-reads the file whose read evidence was summarized away) and re-injects the active todo list, so the model keeps its task list across the summary. There is no per-tool hook to configure. +First-class [memory](../memory) participates in a separate lifecycle. eve asks +providers to capture before compaction, excludes attributed recalled records +from the summarizer, keeps their canonical latest values, and recalls again +after the checkpoint. + Clients and channels can also request compaction between turns. Call `ClientSession.compact()`, a channel route's `compact(address)`, or `attachSession(sessionId).compact()`. The request does not append a user message; @@ -30,6 +35,8 @@ events as automatic compaction, followed by `session.waiting`. To discard model-message history instead of summarizing it, call the corresponding `clear()` method on any of those handles. Clearing preserves the session identity, system prompt, configured tools and skills, durable state, limits, and sandbox. +It removes recalled memory records and framework memory bookkeeping, but it +does not delete data from a memory provider's external store. Its stream boundary is `context.cleared` followed by `session.waiting`. ## What to read next @@ -37,3 +44,4 @@ Its stream boundary is `context.cleared` followed by `session.waiting`. - [Built-in tools](./built-in-tools): review the default and opt-in framework tools and configure the model-facing tool set - [Execution model and durability](./execution-model-and-durability): understand how turns checkpoint and resume - [Context control](./context-control): choose what the model sees and when +- [Memory](../memory): connect scoped, cross-session context to the harness lifecycle diff --git a/docs/concepts/sessions-runs-and-streaming.md b/docs/concepts/sessions-runs-and-streaming.md index c16b95de19..c35c6cd824 100644 --- a/docs/concepts/sessions-runs-and-streaming.md +++ b/docs/concepts/sessions-runs-and-streaming.md @@ -201,9 +201,9 @@ curl -X POST http://127.0.0.1:2000/eve/v1/session//reset \ -d '{"reason":"Start over"}' ``` -Compaction summarizes context without adding a user message. User-role instructions are ordinary history and may be represented by the summary; system-role instructions remain outside it. If a turn is active, eve queues the request until that turn settles. A successful compaction emits `compaction.requested` and `compaction.completed`, followed by `session.waiting`; if summarization fails, the session returns to waiting with its previous history. +Compaction summarizes context without adding a user message. User-role instructions are ordinary history and may be represented by the summary; system-role instructions remain outside it. Attributed [memory](../memory) records are excluded from the summary, canonicalized, and recalled again after the checkpoint. If a turn is active, eve queues the request until that turn settles. A successful compaction emits `compaction.requested` and `compaction.completed`, followed by `session.waiting`; if summarization fails before a checkpoint, the session returns to waiting with its previous history. -Clear removes model-message history in place, including static and dynamic user-role instructions, while preserving the session identity, system-role instructions, tools, skills, durable state, limits, and sandbox. It does not rerun instruction definitions or resolvers. It emits `context.cleared` followed by `session.waiting`. +Clear removes model-message history in place, including static and dynamic user-role instructions and recalled memory records, while preserving the session identity, system-role instructions, tools, skills, application-defined durable state, limits, and sandbox. It clears framework memory locks and replay bookkeeping but does not delete data from a provider's external store. It does not rerun instruction definitions or resolvers. It emits `context.cleared` followed by `session.waiting`. Reset terminally retires the exact session ID. A reset ID never becomes a new session; create another session explicitly for a fresh conversation. Compact, clear, and reset return `"no_active_session"` when the target is already inactive. @@ -244,7 +244,7 @@ Start with the [Client SDK](../guides/client/overview) guide. It covers basic us ## Inspect the agent over HTTP -`GET /eve/v1/info` returns agent-info version 3, a JSON inspection snapshot of the effective compiled agent. It reports the selected config; active tools, instructions, skills, channels, schedules, sandbox, connections, hooks, and instrumentation with explicit source ownership; dynamic resolvers separately from their session-specific output; local and remote agents in separate collections; prepared built-in effects; and shadowed or disabled source diagnostics. Channel routes appear in the same effective order used by the HTTP host. Static instructions remain an ordered array whose entries expose `content` and `role`. +`GET /eve/v1/info` returns agent-info version 4, a JSON inspection snapshot of the effective compiled agent. It reports the selected config; active tools, instructions, memory slots, skills, channels, schedules, sandbox, connections, hooks, and instrumentation with explicit source ownership; dynamic resolvers separately from their session-specific output; local and remote agents in separate collections; prepared built-in effects; and shadowed or disabled source diagnostics. Memory tool wrappers include their selected memory-source dependency. Channel routes appear in the same effective order used by the HTTP host. Static instructions remain an ordered array whose entries expose `content` and `role`. The info route belongs to the selected `channels/eve.ts` source and uses its resolved auth policy. Without an authored replacement, eve selects the default channel source with Vercel OIDC, local development access, and the production placeholder. Replacing or disabling that source replaces or removes the info route too; no native fallback serves it. diff --git a/docs/concepts/state.md b/docs/concepts/state.md index 7b30914c22..0861add350 100644 --- a/docs/concepts/state.md +++ b/docs/concepts/state.md @@ -69,7 +69,13 @@ Every [subagent](../subagents) starts with its own fresh state, whether it's a b ## State vs. connection-side storage -`defineState` holds conversation-scoped working memory that lives and dies with the session, including counters, the current plan, and what the user has told you this conversation. It is the agent's short-term memory, persisted durably for the life of the session. Anything that has to outlive the session, be shared across sessions or users, or be queried independently of a turn belongs in an external store. Start with a packaged memory provider from the [integration gallery](/integrations) using the Memory filter, a general [connection](../connections), or your own database. +`defineState` holds conversation-scoped working memory that lives and dies with +the session, including counters, the current plan, and what the user has told +you this conversation. It is the agent's short-term memory, persisted durably +for the life of the session. For context that must outlive a session, connect a +first-class [memory provider](../memory) to your own application store. Use a +general [connection](../connections) instead when the data should be queried +only through explicit model tool calls rather than recalled automatically. ## What to read next @@ -77,3 +83,4 @@ Every [subagent](../subagents) starts with its own fresh state, whether it's a b - How step durability works → [Execution model & durability](../concepts/execution-model-and-durability) - The `ctx` accessors available alongside state → [TypeScript API Reference](../reference/typescript-api) - Tenant-scoped long-term memory in your own store → [Multi-tenant memory](../patterns/multi-tenant-memory) +- First-class recall, capture, and provider tools → [Memory](../memory) diff --git a/docs/extensions.md b/docs/extensions.md index 950a9101c0..8db6550742 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -7,7 +7,7 @@ Extensions package eve tools, channels, connections, skills, schedules, subagent Ready-made extensions can also be distributed through an eve integration registry. See [Add Integrations](./install-integrations) to discover and add one with `eve add`; this page explains how extension packages are authored, mounted, configured, and overridden. -This enables sharing many different capability sets. A browser extension might include several tools for navigating a site. A memory extension could use hooks to capture context and tools to recall it. A self-improving extension could pair hooks with dynamic instructions. +This enables sharing many different capability sets. A browser extension might include several tools for navigating a site. A self-improving extension could pair hooks with dynamic instructions. ## Author: create an extension @@ -43,7 +43,11 @@ Each listed slot accepts the same authored forms as its agent counterpart. Stati Names come from paths, so call the tool `search`, not `crm_search`; the consumer's mount adds the `crm__` prefix. The same prefix applies to channel, schedule, and parent-visible subagent IDs, while channel route paths and schedule cron expressions stay unchanged. Keep shared code in `extension/lib/`. -The extension root cannot declare agent configuration, a sandbox, or nested extensions. A subagent contributed under `extension/subagents/` owns its own agent configuration and sandbox like any other [declared subagent](./subagents). +The extension root cannot declare agent configuration, [memory](./memory), a +sandbox, or nested extensions. Memory scope and lifecycle state belong to the +consuming application. A subagent contributed under +`extension/subagents/` owns its own agent configuration, memory, and sandbox +like any other [declared subagent](./subagents). ### Add configuration and contributions diff --git a/docs/guides/client/overview.mdx b/docs/guides/client/overview.mdx index 88148682f8..bb5f1a9bc9 100644 --- a/docs/guides/client/overview.mdx +++ b/docs/guides/client/overview.mdx @@ -34,14 +34,14 @@ The client requires the successful response to match `{ ok: true, status: "ready ## Inspect an agent -Use `info()` to inspect an agent. The client requires the complete agent-info version 3 response before returning it: +Use `info()` to inspect an agent. The client requires the complete agent-info version 4 response before returning it: ```ts const info = await client.info(); console.log(info.agent.name, info.agent.model.id); ``` -Version 3 separates active static definitions from dynamic resolvers, includes binding-backed source ownership and composition history, reports local and remote agents separately, and returns the exact compiled channel route order. A non-success response throws `ClientError`; invalid JSON, an earlier schema version, duplicate identities, inconsistent totals, or mismatched source provenance throws `AgentInfoResponseError`. +Version 4 separates active static definitions from dynamic resolvers, includes binding-backed source ownership and composition history, reports first-class memory slots and their provider-tool wrapper dependencies, reports local and remote agents separately, and returns the exact compiled channel route order. A non-success response throws `ClientError`; invalid JSON, an earlier schema version, duplicate identities, inconsistent totals, or mismatched source provenance throws `AgentInfoResponseError`. ## Authentication diff --git a/docs/guides/dynamic-capabilities.md b/docs/guides/dynamic-capabilities.md index a182146028..4304e196c0 100644 --- a/docs/guides/dynamic-capabilities.md +++ b/docs/guides/dynamic-capabilities.md @@ -166,7 +166,7 @@ Write callback properties as inline function expressions, arrows, method shortha Closure values must be JSON-serializable. Plain objects, arrays, strings, finite numbers, booleans, and `null` are supported; `undefined` object properties are omitted. Functions, class instances, `Date`, `Map`, symbols, non-finite numbers, and cyclic values fail resolution with the tool name and callback phase instead of being serialized lossily. -Call expressions such as `execute: makeExecutor()` are not transformed. Put the callback body directly in `defineTool()` inside an authored module; eve-provided factories may also supply pre-registered callbacks. eve rejects a dynamic tool if any present callback lacks durable metadata. +Call expressions such as `execute: makeExecutor()` are not transformed. Put the callback body directly in `defineTool()` inside an authored module; eve-provided factories, including [memory provider tools](../memory), may also supply pre-registered callbacks. eve rejects a dynamic tool if any present callback lacks durable metadata. ### Identity and redeploys @@ -174,7 +174,7 @@ A parked call binds to its callback by **tool name and phase** — the same iden - Editing a callback body (or anything else that does not change tool names) is safe: replaying a parked call runs the latest deployed code with the closure values snapshotted when the call was made. - If a persisted callback has no registered implementation (fresh process after a crash, or after a redeploy), eve re-runs `session.started` resolvers once to rebind it, then replays. -- If the tool no longer exists under that name, replay fails closed with an explicit error instead of invoking something else. Turn-scoped and step-scoped tools are not rebound; a parked call to a missing one errors. +- If the tool no longer exists under that name, replay fails closed with an explicit error instead of invoking something else. Ordinary turn-scoped and step-scoped tools are not rebound; a parked call to a missing one errors. Framework-provided resolvers such as memory provider-tool wrappers opt into the same generic missing-callback rebind while preserving their locked scope. ### Naming @@ -312,3 +312,4 @@ Dynamic system content that changes frequently can reduce provider prompt-cache - The built-in tools and how to override them → [Built-in tools](../concepts/built-in-tools) - Authenticate a tool or connection to an external service → [Auth & route protection](./auth-and-route-protection) - Durable per-session memory for resolvers to read → [State](../concepts/state) +- Cross-session recall and provider-generated tools → [Memory](../memory) diff --git a/docs/memory.md b/docs/memory.md new file mode 100644 index 0000000000..984f735a8e --- /dev/null +++ b/docs/memory.md @@ -0,0 +1,256 @@ +--- +title: "Memory" +description: "Recall and capture scoped, cross-session context with a custom eve memory provider." +--- + +Memory connects an agent to application-owned storage that can outlive one +session. A provider recalls relevant context before a turn, can capture the +settled conversation afterward, and can expose tools that operate on the same +locked scope. + +eve owns the lifecycle and model-facing history. Your provider owns storage, +retrieval, retention, and deletion. eve does not include a built-in filesystem +provider. + +## Add a memory slot + +Create `agent/memory.ts` for one slot named `memory`, or use +`agent/memory/.ts` for named slots. These forms are mutually exclusive. + +```ts title="agent/memory/profile.ts" +import { defineMemory, type MemoryOperationContext } from "eve/memory"; +import { byPrincipal } from "eve/memory/scope"; +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { profileStore } from "../lib/profile-store"; + +async function recallProfile(ctx: MemoryOperationContext) { + const profile = await profileStore.get(ctx.memory.scope.key); + if (profile === null) return null; + + return { + messages: [{ id: "profile", content: JSON.stringify(profile) }], + }; +} + +export default defineMemory({ + description: "Manage durable facts and preferences for the current caller.", + scope: byPrincipal, + provider: { + recall: { + "turn.started": recallProfile, + "compaction.completed": recallProfile, + }, + + capture: { + async "turn.completed"(ctx) { + await profileStore.observe(ctx.memory.scope.key, ctx.messages, { + operationId: ctx.operationId, + }); + }, + }, + + async tools(ctx) { + return { + save: defineTool({ + description: "Save one durable profile fact.", + inputSchema: z.object({ key: z.string(), value: z.string() }), + async execute(input) { + await profileStore.put(ctx.memory.scope.key, input); + return { saved: true }; + }, + }), + }; + }, + }, +}); +``` + +The filename is the slot name. The provider tool above is exposed as +`profile__save`; the slot description is prepended to the tool description. +Provider tools are ordinary `defineTool()` values, so schemas, approvals, and +`toModelOutput` work normally. Their callbacks remain replayable after a +process restart or deployment. + +Use `defineMemoryProvider()` when several slots share one provider or when you +want its contract checked separately: + +```ts +import { defineMemoryProvider, type MemoryOperationContext } from "eve/memory"; + +async function recall(ctx: MemoryOperationContext) { + return await recallFromStore(ctx.memory.scope.key, ctx.messages); +} + +export const provider = defineMemoryProvider({ + recall: { + "turn.started": recall, + "compaction.completed": recall, + }, +}); +``` + +## Choose a scope + +`scope` decides who or what shares memory. Set it to a string or `null`, or use +a resolver that returns a string, a tuple of strings, or `null`. Resolve tenant +and caller identity from trusted authentication or channel metadata, never from +model input: + +```ts title="agent/memory/account.ts" +import { defineMemory } from "eve/memory"; + +export default defineMemory({ + scope: (ctx) => { + const caller = ctx.session.auth.current; + const tenantId = caller?.attributes.tenantId; + + if (caller?.principalType !== "user" || typeof tenantId !== "string") { + return null; + } + + return [tenantId, caller.principalId]; + }, + provider, +}); +``` + +Returning `null` disables the slot for that operation. eve does not call its +namespace resolver, provider, or tools, and it never falls back to a shared +scope. In `eve dev`, a diagnostic names the disabled slot and the resolver +that returned `null`, without logging the resolved value. + +`byPrincipal` uses `auth.current`. It disables memory for anonymous and runtime +principals and returns the shared `local-dev` scope during local development. +Use a custom resolver when the boundary also needs a tenant, channel, or +conversation identifier. + +eve validates the namespace and scope, then gives the provider: + +- `memory.scope.key`: a versioned, opaque digest for storage lookup; +- `memory.scope.namespace`: the resolved namespace; +- `memory.scope.value`: the resolved string or tuple; +- `memory.slot`: the path-derived slot name. + +Use `memory.scope.key` as the provider partition key. It preserves tuple +boundaries and does not persist raw scope components in eve's durable +attribution. + +## Choose a namespace + +The namespace separates an application's memory domains before scope is +applied. Omit `namespace` for `defaultNamespace`, which includes the graph +node and slot plus a deployment-aware identity: + +- production and other Vercel environments use the project and environment; +- Preview also uses the branch or deployment identity; +- local development uses a digest of the application root, never the raw path. + +Redeployments keep the same production namespace. Preview branches do not +share memory accidentally. Set a string or resolver for an explicit domain: + +```ts +export default defineMemory({ + namespace: "acme-support-v1", + scope: byPrincipal, + provider, +}); +``` + +A custom namespace is complete; eve adds no hidden suffix. Returning `null` +disables the slot. Scope resolves first, so a disabled scope never invokes the +namespace resolver. + +## Recall behavior + +Register recall handlers under their lifecycle keys. `"turn.started"` is +required and runs before the model. `"compaction.completed"` is optional and +runs after a compaction checkpoint. A recall handler returns `{ messages }`, +`null`, or `undefined`. Each recalled item becomes an untrusted user-role +message; provider content is never promoted to system instructions. + +```ts +return { + messages: [ + { id: "preferred-language", content: "The user prefers Spanish." }, + { content: "A relevant note without a stable identity." }, + ], +}; +``` + +Use stable `id` values for replaceable facts. A later item with the same ID in +the same slot, namespace, and scope supersedes the earlier value. An identical +value is a no-op. Items without IDs accumulate, even when their content is +identical. Omitting an earlier item from a later result does not delete it. + +All active slots lock their scopes before any recall runs. They see the same +pre-recall history, and eve commits their validated results atomically. Each +call receives a stable `operationId`; use it as an idempotency key for writes +performed by capture handlers. + +The default `visibility: "scope"` hides a slot's prior recalled records when +its scope changes. Set `visibility: "session"` only when those records are +safe to retain for the rest of the session across scope changes. Namespace +and slot boundaries still apply. + +## Lifecycle and compaction + +| Phase | Provider handler | Context | +| ---------------------- | --------------------------------- | ---------------------------------------------------------- | +| `turn.started` | `recall["turn.started"]` | History before recall; current delivery is in `turn.input` | +| `turn.completed` | `capture["turn.completed"]` | Settled, projected history after a successful turn | +| `compaction.requested` | `capture["compaction.requested"]` | Projected history before the checkpoint changes | +| `compaction.completed` | `recall["compaction.completed"]` | The checkpoint plus canonical recalled records | + +Compaction excludes recalled records from the summarizer, preserves only the +latest keyed values plus unkeyed values, then recalls again against the new +checkpoint. This keeps provider context attributable and prevents a summary +from turning it into ordinary conversation history. + +If raw superseded records exceed 512 entries or 256 KiB, eve can canonicalize +them without waiting for the normal token threshold. This folds superseded +records without changing the provider's external storage. + +`clear()` removes conversation history, recalled records, locked scopes, and +memory replay bookkeeping from the session. It does not delete data in the +provider's external store. A later turn can recall that data again. + +## Failure behavior + +- A throwing or invalid turn-start recall fails before the model call. No + slot's recall results are committed. +- A pre-compaction capture failure leaves history unchanged. +- A post-compaction recall failure fails an automatic turn. Standalone + compaction reports a diagnostic and returns the session to waiting because + the checkpoint has already been written. +- An invalid or throwing `tools()` result is diagnosed and omitted for that + turn. +- A completed-turn capture failure is diagnosed after the response and does + not rewrite the completed turn. + +Keep provider operations idempotent, enforce backend size and retention +policies, and treat recalled content as user-controlled data. + +## Limits and overrides + +Namespaces, scope components, and provider item IDs are limited to 1,024 UTF-8 +bytes. Scope tuples accept at most 16 non-empty components, and the combined +canonical namespace and scope input is limited to 4,096 bytes. Provider tool +names must satisfy the normal tool-name grammar after the `__` prefix is +added. + +Set `tools: false` to disable a provider's tool factory while keeping recall +and capture. An application-owned `agent/tools/.ts` also replaces the +generated provider-tool wrapper; export `disableTool()` there to remove it. +Extensions cannot contribute memory slots because scope and lifecycle +ownership remain with the consuming agent or subagent. + +## What to read next + +- [Multi-tenant memory](./patterns/multi-tenant-memory): define a tenant and + caller scope for an application store. +- [State](./concepts/state): keep durable working data inside one session. +- [Default harness](./concepts/default-harness): understand compaction and + context controls. +- [Dynamic capabilities](./guides/dynamic-capabilities): understand the + ordinary dynamic-tool lifecycle used by provider tools. diff --git a/docs/meta.json b/docs/meta.json index 63b6299043..4b3031c204 100644 --- a/docs/meta.json +++ b/docs/meta.json @@ -6,6 +6,7 @@ "---Build---", "agent-config", "instructions", + "memory", "tools", "connections", "channels", diff --git a/docs/patterns/multi-tenant-memory.md b/docs/patterns/multi-tenant-memory.md index 0e97563874..7797722d84 100644 --- a/docs/patterns/multi-tenant-memory.md +++ b/docs/patterns/multi-tenant-memory.md @@ -1,157 +1,118 @@ --- title: "Multi-Tenant Memory" -description: "Compose dynamic instructions, authenticated session context, and ordinary tools into tenant-scoped long-term memory." +description: "Scope a first-class eve memory provider to an authenticated tenant and caller." --- -You can add long-term memory from the [integration gallery](/integrations) using the Memory filter, or build tenant-aware memory from your own application store by composing three existing eve primitives: +First-class [memory](../memory) can load long-term context from your application +store while keeping every provider call and generated tool inside one trusted +tenant-and-caller boundary. -1. route auth puts the tenant and user on `ctx.session.auth`; -2. dynamic instructions load that caller's memories before each turn; -3. ordinary tools write and delete memories in your application store. - -The storage implementation is deliberately outside eve. PostgreSQL, a durable KV store, or a vector database all work as long as every operation is scoped by tenant and user. +The storage implementation remains application-owned. PostgreSQL, a durable KV +store, or a vector database all work as long as the partition key is mandatory +for every read and write. ```text agent/ - instructions/memory.ts - lib/memory-store.ts # your storage adapter - lib/tenant.ts - tools/forget.ts - tools/list_memories.ts - tools/remember.ts + memory/profile.ts + lib/memory-store.ts + instructions.md ``` -## Derive the memory scope from the turn - -Never accept a tenant or user id from the model. Read both from verified session context: - -```ts title="agent/lib/tenant.ts" -import type { SessionContext } from "eve/context"; +## Derive scope from authenticated context -export interface TenantCaller { - tenantId: string; - userId: string; -} - -export function requireTenantCaller(ctx: SessionContext): TenantCaller { - const caller = ctx.session.auth.current; - const tenantId = caller?.attributes.tenantId; +Never accept the tenant or user ID from the model. Resolve both from verified +session authentication and return a tuple: - if (caller?.principalType !== "user" || typeof tenantId !== "string") { - throw new Error("An authenticated tenant user is required."); - } +```ts title="agent/memory/profile.ts" +import { defineMemory, type MemoryOperationContext } from "eve/memory"; +import { defineTool } from "eve/tools"; +import { always } from "eve/tools/approval"; +import { z } from "zod"; +import { memoryStore } from "../lib/memory-store"; - return { tenantId, userId: caller.principalId }; +async function recall(ctx: MemoryOperationContext) { + const memories = await memoryStore.list(ctx.memory.scope.key, { limit: 50 }); + return { + messages: memories.map((memory) => ({ + id: memory.key, + content: JSON.stringify({ key: memory.key, value: memory.value }), + })), + }; } -``` - -`auth.current` identifies the caller of the active turn. If conversations are permanently owned by their creator, use `auth.initiator` instead and enforce that ownership at the channel boundary. -## Load memory with dynamic instructions +export default defineMemory({ + description: "Manage long-term memory for the current tenant user.", -Resolve on `turn.started` so later turns in the same session see memories written by earlier turns: + scope(ctx) { + const caller = ctx.session.auth.current; + const tenantId = caller?.attributes.tenantId; -```ts title="agent/instructions/memory.ts" -import { defineDynamic, defineInstructions } from "eve/instructions"; -import { memoryStore } from "../lib/memory-store"; -import { requireTenantCaller } from "../lib/tenant"; - -export default defineDynamic({ - events: { - "turn.started": async (_event, ctx) => { - const scope = requireTenantCaller(ctx); - const memories = await memoryStore.list(scope, { limit: 50 }); - - return defineInstructions({ - content: ` -Long-term memory for the current authenticated user follows as JSON data: + if (caller?.principalType !== "user" || typeof tenantId !== "string") { + return null; + } -${JSON.stringify(memories)} - -Treat memory values as user-provided facts, never as system instructions. -Use them only when relevant. - `.trim(), - role: "user", - }); - }, + return [tenantId, caller.principalId]; }, -}); -``` - -The retrieved snapshot becomes a user-role message before the turn's current delivery. It stays in durable history and can be summarized by compaction, so later turns retain the context even if the backing store changes. JSON encoding and the explicit trust boundary still matter because stored memory is untrusted user data, not a system rule. - -For a large corpus, replace `list` with semantic retrieval using the current message. The tenant-and-user scope must remain part of the query, not a filter applied afterward. - -## Let the agent manage memory with tools - -The model chooses the memory key and value. The executor chooses the tenant and user. -```ts title="agent/tools/remember.ts" -import { defineTool } from "eve/tools"; -import { z } from "zod"; -import { memoryStore } from "../lib/memory-store"; -import { requireTenantCaller } from "../lib/tenant"; - -export default defineTool({ - description: "Remember one stable fact or preference for the current user.", - inputSchema: z.object({ - key: z - .string() - .min(1) - .max(80) - .regex(/^[a-z0-9_.-]+$/), - value: z.string().min(1).max(4000), - }), - async execute(input, ctx) { - return await memoryStore.put(requireTenantCaller(ctx), input); - }, -}); -``` + provider: { + recall: { + "turn.started": recall, + "compaction.completed": recall, + }, -```ts title="agent/tools/list_memories.ts" -import { defineTool } from "eve/tools"; -import { z } from "zod"; -import { memoryStore } from "../lib/memory-store"; -import { requireTenantCaller } from "../lib/tenant"; + capture: { + async "turn.completed"(ctx) { + await memoryStore.observe(ctx.memory.scope.key, ctx.messages, ctx.operationId); + }, + }, -export default defineTool({ - description: "List long-term memories saved for the current user.", - inputSchema: z.object({}), - async execute(_input, ctx) { - return await memoryStore.list(requireTenantCaller(ctx), { limit: 50 }); + async tools(ctx) { + return { + remember: defineTool({ + description: "Remember one stable fact or preference.", + inputSchema: z.object({ + key: z + .string() + .min(1) + .max(80) + .regex(/^[a-z0-9_.-]+$/), + value: z.string().min(1).max(4000), + }), + async execute(input) { + await memoryStore.put(ctx.memory.scope.key, input); + return { saved: true }; + }, + }), + + forget: defineTool({ + approval: always(), + description: "Delete one long-term memory.", + inputSchema: z.object({ key: z.string().min(1).max(80) }), + async execute({ key }) { + return { deleted: await memoryStore.delete(ctx.memory.scope.key, key) }; + }, + }), + }; + }, }, }); ``` -```ts title="agent/tools/forget.ts" -import { defineTool } from "eve/tools"; -import { always } from "eve/tools/approval"; -import { z } from "zod"; -import { memoryStore } from "../lib/memory-store"; -import { requireTenantCaller } from "../lib/tenant"; - -export default defineTool({ - description: "Delete one long-term memory belonging to the current user.", - inputSchema: z.object({ key: z.string().min(1).max(80) }), - approval: always(), - async execute({ key }, ctx) { - const deleted = await memoryStore.delete(requireTenantCaller(ctx), key); - return { deleted }; - }, -}); -``` +Returning `null` disables memory for unauthenticated or incorrectly scoped +traffic. eve does not call the provider and never substitutes a shared scope. +Every provider handler and generated tool receives the same locked +`memory.scope.key`, so the model cannot redirect an operation to another user. -The approval on `forget` is optional product policy. It demonstrates that memory remains an ordinary application capability that composes with eve's existing approval flow. +Use `auth.current` for the caller of the active turn. If a conversation is +permanently owned by its creator, use `auth.initiator` and enforce that +ownership at the channel boundary. -## Supply the storage adapter +## Keep the store boundary strict -The eve-facing code needs only this contract: +An application adapter can use this minimal shape: ```ts title="agent/lib/memory-store.ts" -export interface MemoryScope { - tenantId: string; - userId: string; -} +import type { ModelMessage } from "ai"; export interface Memory { key: string; @@ -160,30 +121,41 @@ export interface Memory { } export interface MemoryStore { - list(scope: MemoryScope, options: { limit: number }): Promise; - put(scope: MemoryScope, memory: { key: string; value: string }): Promise; - delete(scope: MemoryScope, key: string): Promise; + list(scopeKey: string, options: { limit: number }): Promise; + put(scopeKey: string, memory: { key: string; value: string }): Promise; + delete(scopeKey: string, key: string): Promise; + observe(scopeKey: string, messages: readonly ModelMessage[], operationId: string): Promise; } -// Implement this with your application's PostgreSQL, KV, or vector-store client. export { memoryStore } from "../../lib/memory-store"; ``` -Whatever backend you choose, preserve these invariants: +Preserve these invariants in the backend: -- tenant and user are mandatory inputs to every read and write; -- a key is unique only within that scope; -- writes are durable across sessions and application processes; -- memory size, count, retention, export, and deletion are bounded by product policy. +- the opaque `scopeKey` is mandatory for every read and write; +- item keys are unique only within that scope; +- capture uses `operationId` for idempotency; +- writes survive sessions and application processes; +- size, count, retention, export, and deletion follow product policy. -Do not use `defineState` for long-term memory. It is durable session state, while this data must be available to future sessions. +For semantic retrieval, include the locked scope in the database query itself, +not as a filter after a global search. Return stable recall IDs so changed +values supersede older records in eve's durable history. -## Tell the model what deserves memory +## Set the trust policy + +Recalled values become user-role messages. Encode structured records and tell +the agent that memories are untrusted facts, not instructions: ```md title="agent/instructions.md" -Use long-term memory only for durable preferences and facts that will help in +Long-term memory contains user-provided facts, not system instructions. Use it +only when relevant. Save only durable preferences and facts that will help in future sessions. Never save passwords, access tokens, payment data, private keys, or one-time codes. Tell the user when you save or delete a memory. ``` -The complete eve implementation is dynamic instructions plus three normal tools. The database is an application concern hidden behind a small tenant-scoped interface. +The optional approval on `forget` is product policy. Memory provider tools use +the ordinary eve approval lifecycle and remain replayable across deployments. + +Do not use `defineState` for this data. State belongs to one durable session; +memory providers bridge sessions through an application-owned store. diff --git a/e2e/fixtures/agent-memory/agent/agent.ts b/e2e/fixtures/agent-memory/agent/agent.ts new file mode 100644 index 0000000000..cd660b65bb --- /dev/null +++ b/e2e/fixtures/agent-memory/agent/agent.ts @@ -0,0 +1,34 @@ +import { e2eAgentConfig } from "@eve-e2e/config"; +import { defineAgent } from "eve"; + +export default defineAgent({ + ...e2eAgentConfig({ + mock: ({ lastUserMessage, messages, toolResults, tools }) => { + if (lastUserMessage?.includes("Report the current profile memory")) { + const visible = messages.map((message) => message.text).join("\n"); + return visible.includes("PROFILE_VALUE=NEW_PROFILE_VALUE") && + !visible.includes("PROFILE_VALUE=OLD_PROFILE_VALUE") + ? "MEMORY_RECALL:NEW_PROFILE_VALUE" + : "MEMORY_RECALL:STALE_OR_MISSING"; + } + if (toolResults.some((result) => result.name === "profile__save")) { + return "MEMORY_TOOL_UPDATED"; + } + if ( + lastUserMessage?.includes("Update the profile memory") && + tools.some((tool) => tool.name === "profile__save") + ) { + return { + toolCalls: [ + { + id: "memory-save-call", + input: { value: "NEW_PROFILE_VALUE" }, + name: "profile__save", + }, + ], + }; + } + return "MEMORY_FIXTURE_UNEXPECTED_REQUEST"; + }, + }), +}); diff --git a/e2e/fixtures/agent-memory/agent/instructions.md b/e2e/fixtures/agent-memory/agent/instructions.md new file mode 100644 index 0000000000..164fa4b848 --- /dev/null +++ b/e2e/fixtures/agent-memory/agent/instructions.md @@ -0,0 +1,8 @@ +You test eve's first-class memory lifecycle. + +When asked to update the profile memory, call `profile__save` exactly once with +the requested value, then reply with exactly `MEMORY_TOOL_UPDATED`. + +When asked to report the current profile memory, read the latest recalled +`PROFILE_VALUE=...` record and reply with exactly `MEMORY_RECALL:`. Do +not call a tool on the report turn. diff --git a/e2e/fixtures/agent-memory/agent/memory/profile.ts b/e2e/fixtures/agent-memory/agent/memory/profile.ts new file mode 100644 index 0000000000..4e01bec1fa --- /dev/null +++ b/e2e/fixtures/agent-memory/agent/memory/profile.ts @@ -0,0 +1,31 @@ +import { defineMemory } from "eve/memory"; +import { byPrincipal } from "eve/memory/scope"; +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +const profiles = new Map(); + +export default defineMemory({ + description: "Update the durable profile value for this caller.", + provider: { + recall: { + async "turn.started"(ctx) { + const value = profiles.get(ctx.memory.scope.key) ?? "OLD_PROFILE_VALUE"; + return { messages: [{ content: `PROFILE_VALUE=${value}`, id: "profile" }] }; + }, + }, + async tools(ctx) { + return { + save: defineTool({ + description: "Save the caller's profile value.", + inputSchema: z.object({ value: z.string() }), + async execute({ value }) { + profiles.set(ctx.memory.scope.key, value); + return { saved: true }; + }, + }), + }; + }, + }, + scope: byPrincipal, +}); diff --git a/e2e/fixtures/agent-memory/evals/evals.config.ts b/e2e/fixtures/agent-memory/evals/evals.config.ts new file mode 100644 index 0000000000..7a61a5b55c --- /dev/null +++ b/e2e/fixtures/agent-memory/evals/evals.config.ts @@ -0,0 +1,6 @@ +import { e2eJudgeModel } from "@eve-e2e/config"; +import { defineEvalConfig } from "eve/evals"; + +export default defineEvalConfig({ + judge: { model: e2eJudgeModel() }, +}); diff --git a/e2e/fixtures/agent-memory/evals/memory/custom-provider.eval.ts b/e2e/fixtures/agent-memory/evals/memory/custom-provider.eval.ts new file mode 100644 index 0000000000..b6308d3fac --- /dev/null +++ b/e2e/fixtures/agent-memory/evals/memory/custom-provider.eval.ts @@ -0,0 +1,23 @@ +import { defineEval } from "eve/evals"; + +export default defineEval({ + description: "A custom provider recalls context, mutates through a tool, and supersedes it.", + async test(t) { + const update = await t.send( + "Update the profile memory to NEW_PROFILE_VALUE, then confirm the update.", + ); + update.expectOk(); + update.calledTool("profile__save", { count: 1 }); + update.messageIncludes("MEMORY_TOOL_UPDATED"); + + const recalled = await t.send( + "Report the current profile memory exactly as instructed, without calling a tool.", + ); + recalled.expectOk(); + recalled.messageIncludes("MEMORY_RECALL:NEW_PROFILE_VALUE"); + recalled.usedNoTools(); + + t.succeeded(); + t.calledTool("profile__save", { count: 1 }); + }, +}); diff --git a/e2e/fixtures/agent-memory/package.json b/e2e/fixtures/agent-memory/package.json new file mode 100644 index 0000000000..7451e24008 --- /dev/null +++ b/e2e/fixtures/agent-memory/package.json @@ -0,0 +1,26 @@ +{ + "name": "agent-memory", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "eve build", + "dev": "eve dev", + "start": "eve start", + "typecheck": "eve build && tsc", + "test:e2e": "eve eval --strict" + }, + "dependencies": { + "@eve-e2e/config": "workspace:*", + "@workflow/world-postgres": "catalog:", + "eve": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:" + }, + "e2e": { + "modelMatrix": "default" + } +} diff --git a/e2e/fixtures/agent-memory/tsconfig.json b/e2e/fixtures/agent-memory/tsconfig.json new file mode 100644 index 0000000000..85e3eceb8e --- /dev/null +++ b/e2e/fixtures/agent-memory/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "esnext", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["agent/**/*.ts", "evals/**/*.ts"] +} diff --git a/packages/eve/extension-contracts/compatibility/subagent/v3.ts b/packages/eve/extension-contracts/compatibility/subagent/v3.ts new file mode 100644 index 0000000000..e3bdcaa242 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/subagent/v3.ts @@ -0,0 +1,7 @@ +import { defineAgent } from "#public/index.js"; + +export default defineAgent({ + compaction: { thresholdPercent: 0.8 }, + description: "Delegate research tasks.", + model: "anthropic/claude-sonnet-5", +}); diff --git a/packages/eve/extension-contracts/compatibility/tool/v18.ts b/packages/eve/extension-contracts/compatibility/tool/v18.ts new file mode 100644 index 0000000000..1281296191 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/tool/v18.ts @@ -0,0 +1,3 @@ +import { defaultWebSearch } from "#public/tools/web-search.js"; + +export default defaultWebSearch; diff --git a/packages/eve/extension-contracts/reports/subagent/v4.json b/packages/eve/extension-contracts/reports/subagent/v4.json new file mode 100644 index 0000000000..1afb0b48f8 --- /dev/null +++ b/packages/eve/extension-contracts/reports/subagent/v4.json @@ -0,0 +1,20 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "subagent", + "epoch": 4, + "sha256": "539a5ddb907e4324356534f7a2479887b6a00dfc702e5e6f9bdaf4313266628d", + "exports": [ + "AgentCompactionDefinition", + "AgentDefinition", + "AgentModelDefinition", + "AgentStaticModelDefinition", + "DefinedAgent", + "DynamicLocalSubagentDefinition", + "DynamicSubagentDefinition", + "RemoteAgentDefinition", + "RemoteAgentDefinitionInput", + "defineAgent", + "defineDynamic", + "defineRemoteAgent" + ] +} diff --git a/packages/eve/extension-contracts/reports/tool/v19.json b/packages/eve/extension-contracts/reports/tool/v19.json new file mode 100644 index 0000000000..daa976cf82 --- /dev/null +++ b/packages/eve/extension-contracts/reports/tool/v19.json @@ -0,0 +1,19 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "tool", + "epoch": 19, + "sha256": "05f5a1823d2df82afb41719e78b34ebb98522025e6e598db2cd93e1e7defe8b7", + "exports": [ + "defaultWebSearch", + "defineTool", + "disableTool", + "experimental_workflow", + "isDisabledToolSentinel", + "isExperimentalWorkflowToolDefinition", + "isWebSearchToolDefinition", + "toolOutput", + "toolOutputPart", + "toolResultFrom", + "webSearch" + ] +} diff --git a/packages/eve/package.json b/packages/eve/package.json index 96bbf06401..d339d7efeb 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -101,6 +101,16 @@ "import": "./dist/src/public/tools/approval/index.js", "default": "./dist/src/public/tools/approval/index.js" }, + "./memory": { + "types": "./dist/src/public/memory/index.d.ts", + "import": "./dist/src/public/memory/index.js", + "default": "./dist/src/public/memory/index.js" + }, + "./memory/scope": { + "types": "./dist/src/public/memory/scope.d.ts", + "import": "./dist/src/public/memory/scope.js", + "default": "./dist/src/public/memory/scope.js" + }, "./tools/sleep": { "types": "./dist/src/public/tools/sleep.d.ts", "import": "./dist/src/public/tools/sleep.js", diff --git a/packages/eve/src/client/agent-info-schema.ts b/packages/eve/src/client/agent-info-schema.ts index af86761d8b..db00253c64 100644 --- a/packages/eve/src/client/agent-info-schema.ts +++ b/packages/eve/src/client/agent-info-schema.ts @@ -22,8 +22,10 @@ const moduleBacking = z.discriminatedUnion("kind", [ .strict(), z .object({ + dependencies: z.record(z.string(), z.string()).optional(), kind: z.literal("programmatic"), moduleId: z.string(), + parameters: z.record(z.string(), z.unknown()).optional(), registryId: z.string(), revision: z.string(), semanticRevision: z.string().optional(), @@ -167,6 +169,7 @@ const sourceDescriptor = z moduleBacking, z.object({ kind: z.literal("resource"), sourcePath: z.string() }).strict(), ]), + form: z.enum(["derived", "direct"]), layer: z.enum(["framework-default", "extension-package", "extension-override", "application"]), logicalPath: z.string(), owner, @@ -198,6 +201,15 @@ const connection = source const hook = source.extend({ eventNames: z.array(z.string()), slug: z.string() }).strict(); +const memory = source + .extend({ + description: z.string().optional(), + slot: z.string(), + tools: z.literal(false).optional(), + visibility: z.enum(["scope", "session"]), + }) + .strict(); + const sandbox = source .extend({ backendKind: z.string().optional(), @@ -223,6 +235,7 @@ const subagent = entry connections: z.number(), hooks: z.number(), instructions: z.number(), + memories: z.number(), schedules: z.number(), skills: z.number(), tools: z.number(), @@ -272,7 +285,7 @@ const workflow = z.discriminatedUnion("enabled", [ z.object({ enabled: z.literal(true), source, toolName: z.string() }).strict(), ]); -/** Runtime contract for the authoritative `/eve/v1/info` v3 response. */ +/** Runtime contract for the authoritative `/eve/v1/info` v4 response. */ export const AgentInfoResultSchema = z .object({ agent: z @@ -306,6 +319,7 @@ export const AgentInfoResultSchema = z instrumentation: source.optional(), kernelEffects: z.array(kernelEffect), kind: z.literal("eve-agent-info"), + memories: z.array(memory), mode: z.enum(["development", "production"]), remoteAgents: z.object({ entries: z.array(remoteAgent), total: z.number() }).strict(), sandbox, @@ -313,7 +327,7 @@ export const AgentInfoResultSchema = z skills: z.object({ dynamic: z.array(dynamicResolver), static: z.array(skill) }).strict(), subagents: z.object({ local: z.array(subagent), total: z.number() }).strict(), tools: z.object({ dynamic: z.array(dynamicResolver), static: z.array(tool) }).strict(), - version: z.literal(3), + version: z.literal(4), workflow, workspace: z.object({ resourceRoot: z.unknown(), rootEntries: z.array(z.string()) }).strict(), }) @@ -360,6 +374,7 @@ export const AgentInfoResultSchema = z assertUnique(value.schedules, (entry) => entry.name, ["schedules"]); assertUnique(value.connections, (entry) => entry.connectionName, ["connections"]); assertUnique(value.hooks, (entry) => entry.slug, ["hooks"]); + assertUnique(value.memories, (entry) => entry.slot, ["memories"]); assertUnique( value.channels.routes, (entry) => `${entry.method} ${normalizeRoutePattern(entry.urlPath)}`, @@ -393,6 +408,7 @@ export const AgentInfoResultSchema = z ...value.instructions.static.map( (entry, index) => [entry, ["instructions", "static", index]] as const, ), + ...value.memories.map((entry, index) => [entry, ["memories", index]] as const), ...(value.instrumentation === undefined ? [] : ([[value.instrumentation, ["instrumentation"]]] as const)), @@ -479,4 +495,5 @@ export type AgentInfoChannelEntry = ReadonlyDeep>; export type AgentInfoChannels = AgentInfoResult["channels"]; export type AgentInfoConnectionEntry = ReadonlyDeep>; export type AgentInfoHookEntry = ReadonlyDeep>; +export type AgentInfoMemoryEntry = ReadonlyDeep>; export type AgentInfoSandboxEntry = ReadonlyDeep>; diff --git a/packages/eve/src/client/types.ts b/packages/eve/src/client/types.ts index ec140d4cb6..be66b39967 100644 --- a/packages/eve/src/client/types.ts +++ b/packages/eve/src/client/types.ts @@ -20,6 +20,7 @@ export type { AgentInfoHookEntry, AgentInfoInstructions, AgentInfoInstructionsEntry, + AgentInfoMemoryEntry, AgentInfoResult, AgentInfoRemoteAgentEntry, AgentInfoSandboxEntry, diff --git a/packages/eve/src/compiler/compile-from-memory.test.ts b/packages/eve/src/compiler/compile-from-memory.test.ts index 836a5310f0..6b1bdc66aa 100644 --- a/packages/eve/src/compiler/compile-from-memory.test.ts +++ b/packages/eve/src/compiler/compile-from-memory.test.ts @@ -81,7 +81,7 @@ describe("compileFromMemory", () => { expect(manifest.bindings[skill!.sourceId]?.owner).toEqual({ kind: "application" }); }); - it("preserves roots and passes the serialized v42 schema", async () => { + it("preserves roots and passes the serialized v43 schema", async () => { const { manifest } = await compileFromMemory({ agentRoot: "/app/agent", appRoot: "/app", diff --git a/packages/eve/src/compiler/extension-compatibility.ts b/packages/eve/src/compiler/extension-compatibility.ts index 3006291078..fe00e407b6 100644 --- a/packages/eve/src/compiler/extension-compatibility.ts +++ b/packages/eve/src/compiler/extension-compatibility.ts @@ -22,8 +22,8 @@ interface ExtensionCapabilityContract { const EXTENSION_CAPABILITY_CONTRACTS = { extension: { current: 1, supported: [1], dropped: {} }, tool: { - current: 18, - supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18], + current: 19, + supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19], dropped: { 15: "TaskExec replaces stageEffect with send" }, }, dynamicTool: { @@ -34,8 +34,8 @@ const EXTENSION_CAPABILITY_CONTRACTS = { channel: { current: 9, supported: [1, 2, 3, 4, 5, 6, 7, 8, 9], dropped: {} }, schedule: { current: 3, supported: [1, 2, 3], dropped: {} }, subagent: { - current: 3, - supported: [3], + current: 4, + supported: [3, 4], dropped: { 1: "Persistent subagent sessions are now the default and the experimental opt-in was removed", 2: "Persistent subagent sessions are now the default and the experimental opt-in was removed", diff --git a/packages/eve/src/compiler/load-binding-namespace.ts b/packages/eve/src/compiler/load-binding-namespace.ts index 116c1c530a..6adb069a40 100644 --- a/packages/eve/src/compiler/load-binding-namespace.ts +++ b/packages/eve/src/compiler/load-binding-namespace.ts @@ -2,6 +2,7 @@ import { type AgentSourceRegistry, type CompiledModuleBinding, loadProgrammaticModuleNamespace, + memoizeModuleNamespaceFactories, type ProgrammaticModuleNamespace, } from "#compiler/source-graph.js"; import { packageStateNamespace } from "#discover/extensions.js"; @@ -36,7 +37,7 @@ export function createCompiledBindingNamespaceLoader(input: { binding, loadDependency: (dependencySourceId) => load(dependencySourceId, nextLineage), registries: input.registries, - }); + }).then(memoizeModuleNamespaceFactories); cache.set(sourceId, loading); return loading; }; diff --git a/packages/eve/src/compiler/manifest.ts b/packages/eve/src/compiler/manifest.ts index 2f0667c0bc..f86f1cdf2c 100644 --- a/packages/eve/src/compiler/manifest.ts +++ b/packages/eve/src/compiler/manifest.ts @@ -245,6 +245,7 @@ export interface CompiledWorkflowToolDefinition extends ModuleSourceRef { export interface CompiledDynamicToolDefinition extends ModuleSourceRef { readonly slug: string; readonly eventNames: readonly string[]; + readonly rebindMissingCallbacks?: boolean; /** * Mount namespace when this resolver comes from an extension. The runtime * prefixes the names of tools the resolver produces (`forecast` → @@ -254,6 +255,13 @@ export interface CompiledDynamicToolDefinition extends ModuleSourceRef { readonly extensionNamespace?: string; } +export interface CompiledMemoryDefinition extends ModuleSourceRef { + readonly description?: string; + readonly slot: string; + readonly tools?: false; + readonly visibility: "scope" | "session"; +} + /** * Compiled dynamic skill resolver entry. Mirrors * {@link CompiledDynamicToolDefinition} — the resolver produces skill @@ -808,12 +816,26 @@ const compiledDynamicToolDefinitionSchema: z.ZodType = z + .object({ + description: z.string().optional(), + exportName: z.string().optional(), + logicalPath: z.string(), + slot: z.string(), + sourceId: z.string(), + sourceKind: z.literal("module"), + tools: z.literal(false).optional(), + visibility: z.enum(["scope", "session"]), + }) + .strict(); + const compiledDynamicSkillDefinitionSchema: z.ZodType = z .object({ eventNames: z.array(z.string()).readonly(), @@ -879,6 +901,7 @@ const compiledAgentResourceFields = { dynamicTools: z.array(compiledDynamicToolDefinitionSchema).default([]), extensionMounts: z.array(compiledExtensionMountSchema).default([]), hooks: z.array(compiledHookDefinitionSchema), + memories: z.array(compiledMemoryDefinitionSchema).default([]), sandbox: compiledSandboxDefinitionSchema, sandboxWorkspaces: z.array(compiledSandboxWorkspaceSchema), schedules: z.array(compiledScheduleDefinitionSchema), @@ -986,6 +1009,7 @@ export const compiledAgentManifestSchema = z dynamicSkills: z.array(compiledDynamicSkillDefinitionSchema).default([]), dynamicTools: z.array(compiledDynamicToolDefinitionSchema).default([]), hooks: z.array(compiledHookDefinitionSchema), + memories: z.array(compiledMemoryDefinitionSchema).default([]), kind: z.literal(COMPILED_AGENT_MANIFEST_KIND), remoteAgents: z.array(compiledRemoteAgentNodeSchema), sandbox: compiledSandboxDefinitionSchema, @@ -1016,6 +1040,7 @@ export interface CreateCompiledAgentResourcesInput { readonly dynamicTools?: readonly CompiledDynamicToolDefinition[]; readonly extensionMounts?: readonly CompiledExtensionMount[]; readonly hooks?: readonly CompiledHookDefinition[]; + readonly memories?: readonly CompiledMemoryDefinition[]; readonly remoteAgents?: readonly CompiledRemoteAgentNode[]; readonly sandbox: CompiledSandboxDefinition; readonly sandboxWorkspaces?: readonly CompiledSandboxWorkspace[]; @@ -1055,6 +1080,7 @@ export function createCompiledAgentResources( dynamicTools: [...(input.dynamicTools ?? [])], extensionMounts: [...(input.extensionMounts ?? [])], hooks: [...(input.hooks ?? [])], + memories: [...(input.memories ?? [])], instructions: [...(input.instructions ?? [])], instrumentation: input.instrumentation === undefined ? undefined : { ...input.instrumentation }, remoteAgents: [...(input.remoteAgents ?? [])], diff --git a/packages/eve/src/compiler/module-map.test.ts b/packages/eve/src/compiler/module-map.test.ts index 6cd0eaea74..9426ea963f 100644 --- a/packages/eve/src/compiler/module-map.test.ts +++ b/packages/eve/src/compiler/module-map.test.ts @@ -44,6 +44,7 @@ describe("compiled module maps", () => { } } expect(source).toContain("loadFrameworkProgrammaticModule"); + expect(source).toContain("memoizeModuleNamespaceFactories"); }); it("collects exactly the node binding table plus explicit remote bindings", async () => { diff --git a/packages/eve/src/compiler/module-map.ts b/packages/eve/src/compiler/module-map.ts index c0a3976151..61fb9b34f4 100644 --- a/packages/eve/src/compiler/module-map.ts +++ b/packages/eve/src/compiler/module-map.ts @@ -76,6 +76,7 @@ export function createCompiledModuleMapSource(input: CreateCompiledModuleMapSour importSpecifierStyle, targetPath: binding.backing.sourcePath, }), + initializer: `memoizeModuleNamespaceFactories(imported_${bindingName})`, sourceId, }; } @@ -93,7 +94,9 @@ export function createCompiledModuleMapSource(input: CreateCompiledModuleMapSour const imports = modules.flatMap((module) => module.importSpecifier === undefined ? [] - : [`import * as ${module.bindingName} from ${JSON.stringify(module.importSpecifier)};`], + : [ + `import * as imported_${module.bindingName} from ${JSON.stringify(module.importSpecifier)};`, + ], ); const initializers = modules.flatMap((module) => module.initializer === undefined @@ -103,9 +106,9 @@ export function createCompiledModuleMapSource(input: CreateCompiledModuleMapSour return [ "// Generated by eve. Do not edit by hand.", - ...(usesProgrammaticLoader + ...(usesProgrammaticLoader || imports.length > 0 ? [ - `import { loadFrameworkProgrammaticModule as loadProgrammaticModule } from ${JSON.stringify(normalizeEsmImportSpecifier(input.programmaticLoaderImportSpecifier ?? "eve/internal/programmatic-source-loader"))};`, + `import { loadFrameworkProgrammaticModule as loadProgrammaticModule, memoizeModuleNamespaceFactories } from ${JSON.stringify(normalizeEsmImportSpecifier(input.programmaticLoaderImportSpecifier ?? "eve/internal/programmatic-source-loader"))};`, ] : []), ...imports, diff --git a/packages/eve/src/compiler/normalize-helpers.ts b/packages/eve/src/compiler/normalize-helpers.ts index 13b357f1c9..8a286001bc 100644 --- a/packages/eve/src/compiler/normalize-helpers.ts +++ b/packages/eve/src/compiler/normalize-helpers.ts @@ -9,6 +9,7 @@ import { type AgentSourceRegistry, type CompiledModuleBinding, type AgentSourceOwner, + type ProgrammaticModuleNamespace, } from "#compiler/source-graph.js"; import type { CompiledBindingNamespaceLoader } from "#compiler/load-binding-namespace.js"; @@ -59,6 +60,7 @@ export function requireModuleBackedDefinitionLoadOptions( */ export async function loadModuleBackedDefinition(input: { readonly binding: CompiledModuleBinding; + readonly dependencyNamespaces?: Readonly>; readonly displayPath?: string; readonly kind: string; readonly loadNamespace: CompiledBindingNamespaceLoader; diff --git a/packages/eve/src/compiler/normalize-manifest.test.ts b/packages/eve/src/compiler/normalize-manifest.test.ts index 456a7ad894..30e0474d3a 100644 --- a/packages/eve/src/compiler/normalize-manifest.test.ts +++ b/packages/eve/src/compiler/normalize-manifest.test.ts @@ -18,7 +18,9 @@ import { frameworkAgentSourceRegistry } from "#framework/sources/registry.js"; import { defineAgent } from "#public/definitions/agent.js"; import { defineChannel, GET, POST } from "#public/definitions/channel.js"; import { defineHook } from "#public/definitions/hook.js"; +import { resolveAgent } from "#runtime/resolve-agent.js"; import { defineTool, disableTool } from "#tools/definition.js"; +import { defineMemory } from "#public/memory/index.js"; function manifest() { return createAgentSourceManifest({ @@ -55,7 +57,9 @@ describe("compileAgentManifest source graph", () => { { logicalPath: "agent.ts", loadNamespace: async () => ({ - default: defineAgent({ model: "openai/gpt-5.4" }), + default: defineAgent({ + model: "openai/gpt-5.4", + }), }), }, { @@ -368,6 +372,151 @@ describe("compileAgentManifest source graph", () => { }); }); + it("compiles a selected memory and its provider-tool wrapper through total bindings", async () => { + const definitionFactory = vi.fn(() => + defineMemory({ + description: "Manage the caller profile.", + provider: { + recall: { + "turn.started": async () => ({ + messages: [{ content: "Likes tea", id: "drink" }], + }), + }, + tools: async () => ({ + save: defineTool({ + description: "Save a profile field.", + execute: async () => ({ saved: true }), + inputSchema: { type: "object" }, + }), + }), + }, + scope: "user_1", + }), + ); + const sourceRegistry = registry([ + { + logicalPath: "memory/profile.ts", + loadNamespace: async () => ({ + default: definitionFactory, + }), + }, + ]); + + const compiled = await compileAgentManifest(manifest(), { + sourceRegistries: [sourceRegistry], + }); + const memory = compiled.memories[0]!; + const wrapper = compiled.dynamicTools.find((tool) => tool.slug === "profile")!; + expect(definitionFactory).toHaveBeenCalledTimes(1); + + expect(memory).toMatchObject({ + description: "Manage the caller profile.", + logicalPath: "memory/profile.ts", + slot: "profile", + visibility: "scope", + }); + expect(wrapper).toMatchObject({ + eventNames: ["turn.started"], + logicalPath: "tools/profile.ts", + rebindMissingCallbacks: true, + }); + expect(compiled.bindings[wrapper.sourceId]?.backing).toMatchObject({ + dependencies: { memory: memory.sourceId }, + kind: "programmatic", + parameters: { + memoryExportName: "default", + memoryLogicalPath: "memory/profile.ts", + slot: "profile", + }, + }); + + const moduleMap = await createProgrammaticCompiledModuleMap(compiled, [ + frameworkAgentSourceRegistry, + sourceRegistry, + ]); + expect(() => validateCompiledModuleMap(compiled, moduleMap)).not.toThrow(); + expect(moduleMap.nodes.__root__?.modules[memory.sourceId]).toBeDefined(); + expect(moduleMap.nodes.__root__?.modules[wrapper.sourceId]).toBeDefined(); + expect(definitionFactory).toHaveBeenCalledTimes(2); + + const resolved = await resolveAgent({ manifest: compiled, moduleMap }); + expect(resolved.memories).toHaveLength(1); + expect(definitionFactory).toHaveBeenCalledTimes(2); + }); + + it("lets an application tool replace the derived provider-tool wrapper", async () => { + const sourceRegistry = registry([ + { + logicalPath: "memory/profile.ts", + loadNamespace: async () => ({ + default: defineMemory({ + provider: { recall: { "turn.started": async () => null } }, + scope: "user_1", + }), + }), + }, + { + logicalPath: "tools/profile.ts", + loadNamespace: async () => ({ + default: defineTool({ + description: "Application-owned profile tool.", + execute: async () => null, + inputSchema: {}, + }), + }), + }, + ]); + + const compiled = await compileAgentManifest(manifest(), { + sourceRegistries: [sourceRegistry], + }); + + expect(compiled.memories).toHaveLength(1); + expect(compiled.dynamicTools).not.toContainEqual(expect.objectContaining({ slug: "profile" })); + expect(compiled.tools).toContainEqual( + expect.objectContaining({ + description: "Application-owned profile tool.", + logicalPath: "tools/profile.ts", + name: "profile", + }), + ); + expect(compiled.sourceComposition.entries).toContainEqual( + expect.objectContaining({ + kind: "shadowed", + source: expect.objectContaining({ + logicalPath: "tools/profile.ts", + owner: { feature: "memory", kind: "framework" }, + }), + }), + ); + }); + + it("lets an application disable the derived provider-tool wrapper", async () => { + const sourceRegistry = registry([ + { + logicalPath: "memory/profile.ts", + loadNamespace: async () => ({ + default: defineMemory({ + provider: { recall: { "turn.started": async () => null } }, + scope: "user_1", + }), + }), + }, + { + logicalPath: "tools/profile.ts", + loadNamespace: async () => ({ default: disableTool() }), + }, + ]); + + const compiled = await compileAgentManifest(manifest(), { + sourceRegistries: [sourceRegistry], + }); + + expect(compiled.memories).toHaveLength(1); + expect(compiled.dynamicTools).not.toContainEqual(expect.objectContaining({ slug: "profile" })); + expect(compiled.tools).not.toContainEqual(expect.objectContaining({ name: "profile" })); + }); + it("rejects stale programmatic revisions before loading a namespace", async () => { const loader = vi.fn(async () => ({ default: defineAgent({ model: "openai/gpt-5.4" }) })); const source = defineProgrammaticAgentSource({ diff --git a/packages/eve/src/compiler/normalize-manifest.ts b/packages/eve/src/compiler/normalize-manifest.ts index 55609aa626..49a3bb6bdc 100644 --- a/packages/eve/src/compiler/normalize-manifest.ts +++ b/packages/eve/src/compiler/normalize-manifest.ts @@ -12,6 +12,7 @@ import { type CompiledDynamicSkillDefinition, type CompiledDynamicToolDefinition, type CompiledInstructionsDefinition, + type CompiledMemoryDefinition, type CompiledHookDefinition, type CompiledRemoteAgentNode, type CompiledSkillDefinition, @@ -37,6 +38,7 @@ import { } from "#compiler/normalize-helpers.js"; import { compileHookEntry } from "#compiler/normalize-hook.js"; import { compileInstructionsEntry } from "#compiler/normalize-instructions.js"; +import { compileMemoryDefinition, deriveMemorySlot } from "#compiler/normalize-memory.js"; import { compileSandboxDefinition } from "#compiler/normalize-sandbox.js"; import { compileScheduleDefinition } from "#compiler/normalize-schedule.js"; import { compileSkillSource } from "#compiler/normalize-skill.js"; @@ -75,13 +77,18 @@ import { createProgrammaticModuleCandidates, describeAgentSourceCandidate, disableComposedCandidate, + instantiateProgrammaticTemplate, type AgentModuleCandidate, type AgentSourceCandidate, type AgentSourceLayer, type AgentSourceOwner, type AgentSourceRegistry, + canonicalSourceSlot, } from "#compiler/source-graph.js"; -import { frameworkAgentSourceRegistry } from "#framework/sources/registry.js"; +import { + frameworkAgentSourceRegistry, + memoryWrapperTemplate, +} from "#framework/sources/registry.js"; export interface CompileAgentManifestOptions { readonly diagnostics?: CompilerDiagnostic[]; @@ -366,9 +373,32 @@ class AgentGraphCompiler { (framework ? frameworkCandidates : applicationCandidates).push(...candidates); } } + const memoryWrapperCandidates = [...projected.candidates, ...applicationCandidates] + .filter( + (candidate): candidate is AgentModuleCandidate => + candidate.backing.kind !== "resource" && + (canonicalSourceSlot(candidate.logicalPath) === "memory" || + canonicalSourceSlot(candidate.logicalPath).startsWith("memory/")), + ) + .map((candidate) => { + const slot = deriveMemorySlot(candidate.logicalPath); + return instantiateProgrammaticTemplate({ + anchor: candidate, + dependencies: { memory: candidate }, + logicalPath: `tools/${slot}.ts`, + owner: { feature: "memory", kind: "framework" }, + parameters: { + memoryExportName: candidate.exportName ?? "default", + memoryLogicalPath: candidate.logicalPath, + slot, + }, + template: memoryWrapperTemplate, + }); + }); const orderedCandidates: AgentSourceCandidate[] = [ ...frameworkCandidates, ...projected.candidates, + ...memoryWrapperCandidates, ...applicationCandidates, ]; const composed = composeAgentModuleCandidates(orderedCandidates); @@ -437,6 +467,7 @@ class AgentGraphCompiler { const instructions: CompiledInstructionsDefinition[] = []; const dynamicInstructions: CompiledDynamicInstructionsDefinition[] = []; const connections: CompiledConnectionDefinition[] = []; + const memories: CompiledMemoryDefinition[] = []; const hooks: CompiledHookDefinition[] = []; const schedules: CompiledScheduleDefinition[] = []; const channels: CompiledChannelDefinition[] = []; @@ -505,6 +536,14 @@ class AgentGraphCompiler { case "instrumentation": instrumentation = entry.source; break; + case "memory": + memories.push( + await compileMemoryDefinition(entry.source, { + binding: binding!, + loadNamespace, + }), + ); + break; case "sandbox": sandbox = await compileSandboxDefinition(input.manifest.agentRoot, entry.source, { binding: binding!, @@ -579,6 +618,7 @@ class AgentGraphCompiler { dynamicTools, extensionMounts: compileExtensionMounts(input.manifest, state.composed), hooks, + memories, instructions, instrumentation, sandbox, diff --git a/packages/eve/src/compiler/normalize-memory.ts b/packages/eve/src/compiler/normalize-memory.ts new file mode 100644 index 0000000000..f9fcbf140f --- /dev/null +++ b/packages/eve/src/compiler/normalize-memory.ts @@ -0,0 +1,38 @@ +import { stripLogicalPathExtension } from "#discover/filesystem.js"; +import type { ModuleSourceRef } from "#shared/source-ref.js"; +import type { CompiledMemoryDefinition } from "#compiler/manifest.js"; +import { + loadModuleBackedDefinition, + type ModuleBackedDefinitionLoadOptions, +} from "#compiler/normalize-helpers.js"; +import { normalizeMemoryDefinition } from "#internal/authored-definition/memory.js"; + +export async function compileMemoryDefinition( + source: ModuleSourceRef, + options: ModuleBackedDefinitionLoadOptions, +): Promise { + const definition = normalizeMemoryDefinition( + await loadModuleBackedDefinition({ + binding: options.binding, + kind: "memory", + loadNamespace: options.loadNamespace, + source, + }), + `Expected the memory export "${source.exportName ?? "default"}" from "${source.logicalPath}" to be created with defineMemory().`, + ); + return { + description: definition.description, + exportName: source.exportName, + logicalPath: source.logicalPath, + slot: deriveMemorySlot(source.logicalPath), + sourceId: source.sourceId, + sourceKind: "module", + tools: definition.tools, + visibility: definition.visibility ?? "scope", + }; +} + +export function deriveMemorySlot(logicalPath: string): string { + const extensionless = stripLogicalPathExtension(logicalPath); + return extensionless === "memory" ? "memory" : extensionless.slice("memory/".length); +} diff --git a/packages/eve/src/compiler/normalize-tool.ts b/packages/eve/src/compiler/normalize-tool.ts index df6bb3f9e3..83e1100609 100644 --- a/packages/eve/src/compiler/normalize-tool.ts +++ b/packages/eve/src/compiler/normalize-tool.ts @@ -95,6 +95,7 @@ export async function compileToolEntry( eventNames: [...entry.eventNames], exportName: source.exportName, logicalPath: source.logicalPath, + rebindMissingCallbacks: entry.rebindMissingCallbacks || undefined, slug: toolName, sourceId: source.sourceId, sourceKind: "module", diff --git a/packages/eve/src/compiler/project-sources.ts b/packages/eve/src/compiler/project-sources.ts index 876ff1c99f..d140d93271 100644 --- a/packages/eve/src/compiler/project-sources.ts +++ b/packages/eve/src/compiler/project-sources.ts @@ -35,6 +35,7 @@ export type ProjectedModuleSource = | "hook" | "instructions" | "instrumentation" + | "memory" | "sandbox" | "schedule" | "skill" @@ -70,6 +71,7 @@ const MODULE_KIND_BY_SLOT_ROOT: Partial< hooks: "hook", instructions: "instructions", instrumentation: "instrumentation", + memory: "memory", sandbox: "sandbox", schedules: "schedule", skills: "skill", @@ -304,6 +306,7 @@ function projectManifest(input: { for (const source of input.manifest.channels) pushModule(source); for (const source of input.manifest.connections) pushModule(source); for (const source of input.manifest.hooks) pushModule(source); + for (const source of input.manifest.memories) pushModule(source); if (input.manifest.sandbox !== null) pushModule(input.manifest.sandbox); for (const source of input.manifest.tools) pushModule(source); for (const source of input.manifest.instructions) { diff --git a/packages/eve/src/compiler/source-graph.test.ts b/packages/eve/src/compiler/source-graph.test.ts index e35de48706..dab9146fee 100644 --- a/packages/eve/src/compiler/source-graph.test.ts +++ b/packages/eve/src/compiler/source-graph.test.ts @@ -7,6 +7,7 @@ import { defineProgrammaticAgentSource, instantiateProgrammaticTemplate, loadProgrammaticModuleNamespace, + memoizeModuleNamespaceFactories, type AgentModuleBacking, type AgentModuleCandidate, type AgentSourceLayer, @@ -16,6 +17,7 @@ import { type ProgrammaticAgentSource, type ProgrammaticModuleLoadContext, } from "#compiler/source-graph.js"; +import { materializeAuthoredModuleExport } from "#internal/authored-module.js"; function source( id: string, @@ -43,6 +45,34 @@ function candidate( } describe("derived programmatic sources", () => { + it("memoizes definition factories within one module namespace", async () => { + const factory = vi.fn(() => ({ instance: Symbol("definition") })); + const firstNamespace = memoizeModuleNamespaceFactories({ default: factory }); + const secondNamespace = memoizeModuleNamespaceFactories({ default: factory }); + + const first = await materializeAuthoredModuleExport(firstNamespace.default as () => unknown); + const repeated = await materializeAuthoredModuleExport(firstNamespace.default as () => unknown); + const second = await materializeAuthoredModuleExport(secondNamespace.default as () => unknown); + + expect(repeated).toBe(first); + expect(second).not.toBe(first); + expect(factory).toHaveBeenCalledTimes(2); + }); + + it("does not memoize calls that pass arguments", async () => { + const exported = vi.fn((value?: string) => value ?? { instance: Symbol("definition") }); + const namespace = memoizeModuleNamespaceFactories({ default: exported }); + const callable = namespace.default as (value?: string) => unknown; + + const first = await materializeAuthoredModuleExport(callable); + const repeated = await materializeAuthoredModuleExport(callable); + + expect(repeated).toBe(first); + expect(callable("first")).toBe("first"); + expect(callable("second")).toBe("second"); + expect(exported).toHaveBeenCalledTimes(3); + }); + it("loads registered templates with selected dependencies and serialized parameters", async () => { const dependencyNamespace = { default: { description: "GitHub connection" } }; const loadTemplate = vi.fn(async (context: ProgrammaticModuleLoadContext) => ({ diff --git a/packages/eve/src/compiler/source-graph.ts b/packages/eve/src/compiler/source-graph.ts index 331aa8480a..e79fc2b3ea 100644 --- a/packages/eve/src/compiler/source-graph.ts +++ b/packages/eve/src/compiler/source-graph.ts @@ -9,6 +9,32 @@ import { parseJsonObject, type JsonObject } from "#shared/json.js"; export type ProgrammaticModuleNamespace = Readonly>; +/** Shares zero-argument definition-factory results within one module-map load. */ +export function memoizeModuleNamespaceFactories( + namespace: ProgrammaticModuleNamespace, +): ProgrammaticModuleNamespace { + return Object.freeze( + Object.fromEntries( + Object.entries(namespace).map(([exportName, exportValue]) => { + if (typeof exportValue !== "function") return [exportName, exportValue]; + let invocation: Promise | undefined; + const memoized = new Proxy(exportValue, { + apply(target, thisArgument, argumentsList) { + if (argumentsList.length > 0) { + return Reflect.apply(target, thisArgument, argumentsList); + } + invocation ??= Promise.resolve().then(() => + Reflect.apply(target, thisArgument, argumentsList), + ); + return invocation; + }, + }); + return [exportName, memoized]; + }), + ), + ); +} + export interface ProgrammaticModuleLoadContext { readonly dependencies: Readonly>; readonly parameters: JsonObject; @@ -580,13 +606,21 @@ export function validateProgrammaticLogicalPath(input: string): string { const root = segments[0]; const extensionless = stripLogicalPathExtension(logicalPath); const supported = - (segments.length === 1 && ["agent", "sandbox", "instrumentation"].includes(extensionless)) || + (segments.length === 1 && + ["agent", "memory", "sandbox", "instrumentation"].includes(extensionless)) || (root === "sandbox" && segments.length === 2 && getSupportedModuleBaseName(fileName) === "sandbox") || - (["channels", "connections", "hooks", "instructions", "schedules", "skills", "tools"].includes( - root!, - ) && + ([ + "channels", + "connections", + "hooks", + "instructions", + "memory", + "schedules", + "skills", + "tools", + ].includes(root!) && segments.length >= 2); if (!supported) { throw new Error( diff --git a/packages/eve/src/compiler/validate-artifact.ts b/packages/eve/src/compiler/validate-artifact.ts index 2467b300c2..d6c2068fe7 100644 --- a/packages/eve/src/compiler/validate-artifact.ts +++ b/packages/eve/src/compiler/validate-artifact.ts @@ -168,6 +168,10 @@ export function validateCompiledAgentResources( node.hooks.map((entry) => ({ identity: entry.slug, kind: "hook" })), "hook slug", ); + validateUniqueIdentities( + node.memories.map((entry) => ({ identity: entry.slot, kind: "memory" })), + "memory slot", + ); validateUniqueIdentities( node.schedules.map((entry) => ({ identity: entry.name, kind: "schedule" })), "schedule name", @@ -318,6 +322,7 @@ function collectReferencedModuleSources( for (const value of node.dynamicSkills) add(value); for (const value of node.dynamicTools) add(value); for (const value of node.hooks) add(value); + for (const value of node.memories) add(value); for (const value of node.instructions) if (value.sourceKind === "module") add(value); if (node.instrumentation !== undefined) add(node.instrumentation); if (node.workflowTool !== undefined) add(node.workflowTool); diff --git a/packages/eve/src/context/dynamic-tool-lifecycle.ts b/packages/eve/src/context/dynamic-tool-lifecycle.ts index bfd11935be..4dfc9aaa61 100644 --- a/packages/eve/src/context/dynamic-tool-lifecycle.ts +++ b/packages/eve/src/context/dynamic-tool-lifecycle.ts @@ -1,7 +1,7 @@ import type { ModelMessage } from "ai"; import { replayDynamicTools } from "#context/build-dynamic-tools.js"; -import type { ContextContainer } from "#context/container.js"; +import { contextStorage, type ContextContainer } from "#context/container.js"; import type { ContextKey } from "#context/key.js"; import { SessionDynamicToolMetadataKey, @@ -377,3 +377,31 @@ export async function refreshDynamicSessionToolsForRuntimeRevision(input: { input.ctx.set(SessionDynamicToolMetadataKey, metadata); input.ctx.set(SessionDynamicToolRuntimeRevisionKey, input.runtimeRevision); } + +/** Re-registers callbacks for compiled resolvers that explicitly support cold replay. */ +export async function rebindMissingCompiledDynamicToolCallbacks(input: { + readonly ctx: ContextContainer; + readonly event: UnstampedMessageStreamEvent; + readonly messages: readonly ModelMessage[]; + readonly resolvers: readonly ResolvedDynamicToolResolver[]; +}): Promise { + const persisted = input.ctx.get(TurnDynamicToolMetadataKey) ?? []; + const missing = persisted.filter((entry) => hasUnregisteredDurableDynamicCallbacks([entry])); + if (missing.length === 0) return; + const missingSlugs = new Set(missing.map((entry) => entry.resolverSlug)); + const matching = input.resolvers.filter( + (resolver) => resolver.rebindMissingCallbacks === true && missingSlugs.has(resolver.slug), + ); + if (matching.length === 0) return; + + await contextStorage.run( + input.ctx, + async () => await resolveToolsFromEvent(input.ctx, matching, input.event, input.messages), + ); + const unresolved = missing.filter((entry) => hasUnregisteredDurableDynamicCallbacks([entry])); + if (unresolved.length > 0) { + throw new Error( + `Dynamic tool callback rebind did not restore: ${unresolved.map((entry) => entry.name).join(", ")}. The tool may have been renamed or removed.`, + ); + } +} diff --git a/packages/eve/src/context/keys.ts b/packages/eve/src/context/keys.ts index fb9b2943d1..be99e46ab1 100644 --- a/packages/eve/src/context/keys.ts +++ b/packages/eve/src/context/keys.ts @@ -28,6 +28,7 @@ import type { SandboxAccess } from "#sandbox/state.js"; import type { RunMode } from "#shared/run-mode.js"; import type { RuntimeModelReference } from "#runtime/agent/bootstrap.js"; import type { PreparedRuntimeDelegationTool } from "#runtime/sessions/turn.js"; +import type { MemoryScope, MemoryTurnContext } from "#public/memory/index.js"; // Re-export so consumers don't need a direct channel/ import. export type { SessionAuthContext, SessionParent, SessionTurn } from "#channel/types.js"; @@ -204,6 +205,45 @@ export const TurnDynamicToolMetadataKey = new ContextKey>>( + "eve.memory.turnLocks", +); + +export interface PreparedMemoryPreamble { + readonly history: readonly ModelMessage[]; + readonly input: readonly ModelMessage[]; + readonly state?: Readonly>; +} + +export interface PendingMemoryCommit { + readonly history: readonly ModelMessage[]; + readonly projectedMessages: readonly ModelMessage[]; + readonly state: Readonly>; +} + +export const PreparedMemoryPreambleKey = new ContextKey( + "eve.memory.preparedPreamble", +); +export const PendingMemoryCommitKey = new ContextKey( + "eve.memory.pendingCommit", +); + +export interface PreparedMemoryCompaction { + readonly history: readonly ModelMessage[]; + readonly state?: Readonly>; +} + +export const PreparedMemoryCompactionKey = new ContextKey( + "eve.memory.preparedCompaction", +); + /** Step-scoped dynamic tool metadata, replaced before each model step. */ export const StepDynamicToolMetadataKey = new ContextKey( "eve.stepDynamicToolMetadata", diff --git a/packages/eve/src/context/memory-event-lifecycle.ts b/packages/eve/src/context/memory-event-lifecycle.ts new file mode 100644 index 0000000000..c4ac6984ec --- /dev/null +++ b/packages/eve/src/context/memory-event-lifecycle.ts @@ -0,0 +1,70 @@ +import type { ModelMessage } from "ai"; + +import type { AlsContext } from "#context/container.js"; +import { + dispatchMemoryCompactionCompleted, + dispatchMemoryCompactionRequested, + dispatchMemoryTurnCompleted, + dispatchMemoryTurnStarted, +} from "#context/memory-lifecycle.js"; +import { createLogger } from "#internal/logging.js"; +import type { UnstampedMessageStreamEvent } from "#protocol/message.js"; +import type { ResolvedMemoryDefinition } from "#runtime/types.js"; + +const log = createLogger("memory"); + +export async function dispatchMemoryLifecycleEvent(input: { + readonly abortSignal?: AbortSignal; + readonly appRoot: string; + readonly ctx: AlsContext; + readonly event: UnstampedMessageStreamEvent; + readonly memories: readonly ResolvedMemoryDefinition[]; + readonly messages?: readonly ModelMessage[]; + readonly nodeId: string; +}): Promise { + let messages = input.messages ?? []; + if (input.memories.length === 0) return messages; + + if (input.event.type === "turn.started") { + return await dispatchMemoryTurnStarted({ + abortSignal: input.abortSignal, + appRoot: input.appRoot, + ctx: input.ctx, + event: input.event, + memories: input.memories, + nodeId: input.nodeId, + }); + } + if (input.event.type === "compaction.requested") { + await dispatchMemoryCompactionRequested({ + abortSignal: input.abortSignal, + appRoot: input.appRoot, + ctx: input.ctx, + event: input.event, + memories: input.memories, + messages, + nodeId: input.nodeId, + }); + } else if (input.event.type === "compaction.completed") { + messages = await dispatchMemoryCompactionCompleted({ + abortSignal: input.abortSignal, + ctx: input.ctx, + event: input.event, + memories: input.memories, + messages, + }); + } else if (input.event.type === "turn.completed" && input.messages !== undefined) { + try { + await dispatchMemoryTurnCompleted({ + abortSignal: input.abortSignal, + ctx: input.ctx, + event: input.event, + memories: input.memories, + messages, + }); + } catch (error) { + log.error("Completed-turn memory capture failed.", { error }); + } + } + return messages; +} diff --git a/packages/eve/src/context/memory-lifecycle.test.ts b/packages/eve/src/context/memory-lifecycle.test.ts new file mode 100644 index 0000000000..6a9ce8000a --- /dev/null +++ b/packages/eve/src/context/memory-lifecycle.test.ts @@ -0,0 +1,368 @@ +import type { ModelMessage } from "ai"; +import { describe, expect, it, vi } from "vitest"; + +import { ContextContainer, contextStorage } from "#context/container.js"; +import { + dispatchMemoryCompactionCompleted, + dispatchMemoryCompactionRequested, + dispatchMemoryTurnCompleted, + dispatchMemoryTurnStarted, + drainMemoryCommit, + prepareMemoryCompaction, + prepareMemoryPreamble, +} from "#context/memory-lifecycle.js"; +import { AuthKey, SessionIdKey, SessionKey, TurnMemoryLocksKey } from "#context/keys.js"; +import { + defineMemory, + type MemoryDefinition, + type MemoryTurnCompletedContext, +} from "#public/memory/index.js"; +import type { ResolvedMemoryDefinition } from "#runtime/types.js"; +import { + applyMemoryRecallBatches, + createMemoryLock, + validateMemoryRecallResult, +} from "#shared/memory-state.js"; + +const turnStarted = { + data: { sequence: 0, turnId: "turn_0" }, + type: "turn.started" as const, +}; + +function createContext() { + const auth = { + attributes: {}, + authenticator: "test", + principalId: "user_1", + principalType: "user", + }; + const ctx = new ContextContainer(); + ctx.set(AuthKey, auth); + ctx.set(SessionIdKey, "session_1"); + ctx.set(SessionKey, { + auth: { current: auth, initiator: auth }, + sessionId: "session_1", + turn: { id: "turn_0", sequence: 0 }, + }); + return ctx; +} + +function memory(slot: string, definition: MemoryDefinition): ResolvedMemoryDefinition { + return { + ...defineMemory(definition), + logicalPath: `memory/${slot}.ts`, + slot, + sourceId: `memory/${slot}.ts`, + sourceKind: "module", + visibility: definition.visibility ?? "scope", + }; +} + +describe("memory lifecycle", () => { + it("ignores turn events that are not an authored turn preamble", async () => { + const ctx = createContext(); + const recall = vi.fn(async () => null); + + const projected = await contextStorage.run( + ctx, + async () => + await dispatchMemoryTurnStarted({ + appRoot: "/app", + ctx, + event: turnStarted, + memories: [ + memory("profile", { + provider: { recall: { "turn.started": recall } }, + scope: "user_1", + }), + ], + nodeId: "__root__", + }), + ); + + expect(projected).toEqual([]); + expect(recall).not.toHaveBeenCalled(); + expect(ctx.get(TurnMemoryLocksKey)).toBeUndefined(); + }); + + it("locks every scope before recalling slots against one pre-recall view", async () => { + const ctx = createContext(); + const events: string[] = []; + const seen: ModelMessage[][] = []; + const memories = ["bravo", "alpha"].map((slot) => + memory(slot, { + namespace: async () => { + events.push(`${slot}:namespace`); + return "app"; + }, + provider: { + recall: { + "turn.started": async (context) => { + events.push(`${slot}:recall`); + seen.push([...context.messages]); + return { messages: [{ content: `${slot} memory`, id: "item" }] }; + }, + }, + }, + scope: async () => { + events.push(`${slot}:scope`); + return "user_1"; + }, + }), + ); + const history: ModelMessage[] = [{ content: "prior", role: "assistant" }]; + const input: ModelMessage[] = [{ content: "current", role: "user" }]; + prepareMemoryPreamble(ctx, { history, input }); + + const projected = await contextStorage.run( + ctx, + async () => + await dispatchMemoryTurnStarted({ + appRoot: "/app", + ctx, + event: turnStarted, + memories, + nodeId: "__root__", + }), + ); + const commit = drainMemoryCommit(ctx)!; + + expect(events.indexOf("alpha:recall")).toBeGreaterThan(events.indexOf("bravo:namespace")); + expect(events.indexOf("bravo:recall")).toBeGreaterThan(events.indexOf("alpha:namespace")); + expect(seen).toEqual([[history[0]!], [history[0]!]]); + expect(projected).toEqual([ + history[0], + { content: "alpha memory", role: "user" }, + { content: "bravo memory", role: "user" }, + input[0], + ]); + expect(JSON.stringify(commit.history)).toContain("eve.memory"); + }); + + it("commits no slot when one turn-wide recall batch is invalid", async () => { + const ctx = createContext(); + const valid = vi.fn(async () => ({ messages: [{ content: "valid" }] })); + const invalid = vi.fn(async () => ({ messages: [{ content: " " }] })); + prepareMemoryPreamble(ctx, { history: [], input: [] }); + + await expect( + contextStorage.run( + ctx, + async () => + await dispatchMemoryTurnStarted({ + appRoot: "/app", + ctx, + event: turnStarted, + memories: [ + memory("alpha", { + provider: { recall: { "turn.started": valid } }, + scope: "user_1", + }), + memory("bravo", { + provider: { recall: { "turn.started": invalid } }, + scope: "user_1", + }), + ], + nodeId: "__root__", + }), + ), + ).rejects.toThrow("content must be non-blank"); + + expect(valid).toHaveBeenCalledOnce(); + expect(invalid).toHaveBeenCalledOnce(); + expect(drainMemoryCommit(ctx)).toBeUndefined(); + }); + + it("resolves scope before namespace and skips the entire disabled slot", async () => { + const ctx = createContext(); + const namespace = vi.fn(() => "app"); + const recall = vi.fn(async () => null); + prepareMemoryPreamble(ctx, { history: [], input: [] }); + + await contextStorage.run( + ctx, + async () => + await dispatchMemoryTurnStarted({ + appRoot: "/app", + ctx, + event: turnStarted, + memories: [ + memory("profile", { + namespace, + provider: { recall: { "turn.started": recall } }, + scope: null, + }), + ], + nodeId: "__root__", + }), + ); + + expect(namespace).not.toHaveBeenCalled(); + expect(recall).not.toHaveBeenCalled(); + expect(drainMemoryCommit(ctx)?.history).toEqual([]); + }); + + it("captures only the settled projected history for a successful turn", async () => { + const ctx = createContext(); + const capture = vi.fn(async (_context: MemoryTurnCompletedContext) => {}); + const definition = memory("profile", { + provider: { + capture: { "turn.completed": capture }, + recall: { + "turn.started": async () => ({ + messages: [{ content: "remembered", id: "profile" }], + }), + }, + }, + scope: "user_1", + }); + prepareMemoryPreamble(ctx, { history: [], input: [{ content: "hello", role: "user" }] }); + await contextStorage.run( + ctx, + async () => + await dispatchMemoryTurnStarted({ + appRoot: "/app", + ctx, + event: turnStarted, + memories: [definition], + nodeId: "__root__", + }), + ); + const commit = drainMemoryCommit(ctx)!; + const settled = [ + ...commit.history, + { content: "hello", role: "user" as const }, + { content: "hi", role: "assistant" as const }, + ]; + + await contextStorage.run( + ctx, + async () => + await dispatchMemoryTurnCompleted({ + ctx, + event: { data: { sequence: 0, turnId: "turn_0" }, type: "turn.completed" }, + memories: [definition], + messages: settled, + }), + ); + + expect(capture).toHaveBeenCalledOnce(); + expect(capture.mock.calls[0]?.[0]).toMatchObject({ + messages: [ + { content: "remembered", role: "user" }, + { content: "hello", role: "user" }, + { content: "hi", role: "assistant" }, + ], + operationId: "eve-memory-operation-v1:session_1:0:turn_0:turn.completed:profile", + }); + expect(capture.mock.calls[0]?.[0]).not.toHaveProperty("phase"); + expect(capture.mock.calls[0]?.[0]).not.toHaveProperty("compaction"); + expect(JSON.stringify(capture.mock.calls[0]?.[0].messages)).not.toContain("eve.memory"); + }); + + it("captures before compaction and recalls again after the checkpoint", async () => { + const ctx = createContext(); + const phases: string[] = []; + const definition = memory("profile", { + provider: { + capture: { + "compaction.requested": async (context) => { + phases.push("compaction.requested"); + expect(context).not.toHaveProperty("phase"); + expect(context.compaction).toEqual({ + modelId: "openai/test", + usageInputTokens: 100, + }); + expect(JSON.stringify(context.messages)).not.toContain("eve.memory"); + }, + }, + recall: { + "turn.started": async () => ({ + messages: [{ content: "old profile", id: "profile" }], + }), + "compaction.completed": async (context) => { + phases.push("compaction.completed"); + expect(context).not.toHaveProperty("phase"); + expect(context.compaction).toEqual({ modelId: "openai/test" }); + return { + messages: [{ content: "new profile", id: "profile" }], + }; + }, + }, + }, + scope: "user_1", + }); + const memoryLock = createMemoryLock({ + namespace: "app", + scope: "user_1", + slot: "profile", + turn: { id: "turn_0", input: [], sequence: 0 }, + visibility: "scope", + }); + const recalled = applyMemoryRecallBatches({ + batches: [ + { + lock: memoryLock, + messages: validateMemoryRecallResult( + { messages: [{ content: "old profile", id: "profile" }] }, + "profile", + ), + operationId: "initial", + }, + ], + history: [{ content: "ordinary", role: "user" }], + state: undefined, + }); + ctx.set(TurnMemoryLocksKey, { profile: memoryLock }); + const requested = { + data: { + modelId: "openai/test", + sequence: 0, + sessionId: "session_1", + turnId: "turn_0", + usageInputTokens: 100, + }, + type: "compaction.requested" as const, + }; + prepareMemoryCompaction(ctx, { history: recalled.history, state: recalled.state }); + + await contextStorage.run( + ctx, + async () => + await dispatchMemoryCompactionRequested({ + appRoot: "/app", + ctx, + event: requested, + memories: [definition], + messages: [{ content: "ordinary", role: "user" }], + nodeId: "__root__", + }), + ); + prepareMemoryCompaction(ctx, { history: recalled.history, state: recalled.state }); + const projected = await contextStorage.run( + ctx, + async () => + await dispatchMemoryCompactionCompleted({ + ctx, + event: { + data: { + modelId: "openai/test", + sequence: 0, + sessionId: "session_1", + turnId: "turn_0", + }, + type: "compaction.completed", + }, + memories: [definition], + messages: [], + }), + ); + + expect(phases).toEqual(["compaction.requested", "compaction.completed"]); + expect(projected).toEqual([ + { content: "ordinary", role: "user" }, + { content: "new profile", role: "user" }, + ]); + expect(drainMemoryCommit(ctx)?.history).toHaveLength(3); + }); +}); diff --git a/packages/eve/src/context/memory-lifecycle.ts b/packages/eve/src/context/memory-lifecycle.ts new file mode 100644 index 0000000000..9caf97c974 --- /dev/null +++ b/packages/eve/src/context/memory-lifecycle.ts @@ -0,0 +1,401 @@ +import type { ModelMessage } from "ai"; + +import type { AlsContext } from "#context/container.js"; +import { + PendingMemoryCommitKey, + PreparedMemoryCompactionKey, + PreparedMemoryPreambleKey, + TurnMemoryLocksKey, +} from "#context/keys.js"; +import { buildCallbackContext } from "#context/build-callback-context.js"; +import { buildResolveContext } from "#context/dynamic-resolve-context.js"; +import type { + CompactionCompletedStreamEvent, + CompactionRequestedStreamEvent, + TurnCompletedStreamEvent, + TurnStartedStreamEvent, +} from "#protocol/message.js"; +import { isEveDevEnvironment } from "#internal/application/dev-environment.js"; +import { createLogger } from "#internal/logging.js"; +import { defaultNamespace, type MemoryScopeContext } from "#public/memory/index.js"; +import type { ResolvedMemoryDefinition } from "#runtime/types.js"; +import { + applyMemoryRecallBatches, + createMemoryLock, + projectMemoryHistory, + validateMemoryRecallResult, + type InternalMemoryLock, + type MemoryRecallBatch, +} from "#shared/memory-state.js"; + +const fallbackAbortSignal = new AbortController().signal; +const log = createLogger("memory"); + +export function prepareMemoryPreamble( + ctx: AlsContext, + input: { + readonly history: readonly ModelMessage[]; + readonly input: readonly ModelMessage[]; + readonly state?: Readonly>; + }, +): void { + ctx.setVirtualContext(PreparedMemoryPreambleKey, { + history: input.history, + input: input.input, + state: input.state, + }); +} + +export async function dispatchMemoryTurnStarted(input: { + readonly abortSignal?: AbortSignal; + readonly appRoot: string; + readonly ctx: AlsContext; + readonly event: TurnStartedStreamEvent; + readonly memories: readonly ResolvedMemoryDefinition[]; + readonly nodeId: string; +}): Promise { + const prepared = input.ctx.get(PreparedMemoryPreambleKey); + if (prepared === undefined) return []; + const turn = Object.freeze({ + id: input.event.data.turnId, + input: Object.freeze([...prepared.input]), + sequence: input.event.data.sequence, + }); + const locks = Object.fromEntries( + ( + await Promise.all( + [...input.memories] + .sort((left, right) => left.logicalPath.localeCompare(right.logicalPath)) + .map(async (memory) => { + const lock = await resolveMemoryLock({ + abortSignal: input.abortSignal ?? fallbackAbortSignal, + appRoot: input.appRoot, + ctx: input.ctx, + memory, + nodeId: input.nodeId, + turn, + }); + return lock === null ? null : ([memory.slot, lock] as const); + }), + ) + ).filter((entry): entry is readonly [string, InternalMemoryLock] => entry !== null), + ); + input.ctx.set(TurnMemoryLocksKey, locks); + + const preRecallMessages = projectMemoryHistory({ locks, messages: prepared.history }); + const callbackContext = buildCallbackContext(); + const batches = ( + await Promise.all( + [...input.memories] + .sort((left, right) => left.logicalPath.localeCompare(right.logicalPath)) + .map(async (memory): Promise => { + const lock = locks[memory.slot]; + if (lock === undefined) return null; + const operationId = memoryOperationId({ + phase: "turn.started", + sequence: input.event.data.sequence, + sessionId: callbackContext.session.id, + slot: memory.slot, + turnId: turn.id, + }); + const result = await memory.provider.recall["turn.started"]({ + ...callbackContext, + abortSignal: input.abortSignal ?? fallbackAbortSignal, + memory: { scope: lock.scope, slot: memory.slot }, + messages: preRecallMessages, + operationId, + turn, + }); + return { + lock, + messages: validateMemoryRecallResult(result, memory.slot), + operationId, + }; + }), + ) + ).filter((batch): batch is MemoryRecallBatch => batch !== null); + + const committed = applyMemoryRecallBatches({ + batches, + history: prepared.history, + state: prepared.state, + }); + const projectedMessages = [ + ...projectMemoryHistory({ locks, messages: committed.history }), + ...prepared.input, + ]; + input.ctx.setVirtualContext(PendingMemoryCommitKey, { + history: committed.history, + projectedMessages, + state: committed.state, + }); + return projectedMessages; +} + +export function drainMemoryCommit(ctx: AlsContext) { + const commit = ctx.get(PendingMemoryCommitKey); + ctx.delete(PendingMemoryCommitKey); + ctx.delete(PreparedMemoryPreambleKey); + return commit; +} + +export function prepareMemoryCompaction( + ctx: AlsContext, + input: { + readonly history: readonly ModelMessage[]; + readonly state?: Readonly>; + }, +): void { + ctx.setVirtualContext(PreparedMemoryCompactionKey, input); +} + +export async function dispatchMemoryCompactionRequested(input: { + readonly abortSignal?: AbortSignal; + readonly appRoot: string; + readonly ctx: AlsContext; + readonly event: CompactionRequestedStreamEvent; + readonly memories: readonly ResolvedMemoryDefinition[]; + readonly messages: readonly ModelMessage[]; + readonly nodeId: string; +}): Promise { + const callbackContext = buildCallbackContext(); + const locks = await resolveCompactionLocks(input); + input.ctx.set(TurnMemoryLocksKey, locks); + const turn = input.event.data.turnId.length === 0 ? null : firstLockedTurn(locks); + await Promise.all( + [...input.memories] + .sort((left, right) => left.logicalPath.localeCompare(right.logicalPath)) + .map(async (memory) => { + const lock = locks[memory.slot]; + const capture = memory.provider.capture?.["compaction.requested"]; + if (lock === undefined || capture === undefined) return; + await capture({ + ...callbackContext, + abortSignal: input.abortSignal ?? fallbackAbortSignal, + compaction: { + modelId: input.event.data.modelId, + usageInputTokens: input.event.data.usageInputTokens, + }, + memory: { scope: lock.scope, slot: memory.slot }, + messages: input.messages, + operationId: memoryOperationId({ + phase: "compaction.requested", + sequence: input.event.data.sequence, + sessionId: callbackContext.session.id, + slot: memory.slot, + turnId: turn?.id ?? null, + }), + turn, + }); + }), + ); +} + +export async function dispatchMemoryCompactionCompleted(input: { + readonly abortSignal?: AbortSignal; + readonly ctx: AlsContext; + readonly event: CompactionCompletedStreamEvent; + readonly memories: readonly ResolvedMemoryDefinition[]; + readonly messages: readonly ModelMessage[]; +}): Promise { + const prepared = input.ctx.get(PreparedMemoryCompactionKey); + const rawHistory = prepared?.history ?? input.messages; + const locks = input.ctx.get(TurnMemoryLocksKey) as + | Readonly> + | undefined; + const activeLocks = locks ?? {}; + const projected = projectMemoryHistory({ locks: activeLocks, messages: rawHistory }); + const callbackContext = buildCallbackContext(); + const turn = input.event.data.turnId.length === 0 ? null : firstLockedTurn(activeLocks); + const batches = ( + await Promise.all( + [...input.memories] + .sort((left, right) => left.logicalPath.localeCompare(right.logicalPath)) + .map(async (memory): Promise => { + const lock = activeLocks[memory.slot]; + const recall = memory.provider.recall["compaction.completed"]; + if (lock === undefined || recall === undefined) return null; + const operationId = memoryOperationId({ + phase: "compaction.completed", + sequence: input.event.data.sequence, + sessionId: callbackContext.session.id, + slot: memory.slot, + turnId: turn?.id ?? null, + }); + const result = await recall({ + ...callbackContext, + abortSignal: input.abortSignal ?? fallbackAbortSignal, + compaction: { modelId: input.event.data.modelId }, + memory: { scope: lock.scope, slot: memory.slot }, + messages: projected, + operationId, + turn, + }); + return { + lock, + messages: validateMemoryRecallResult(result, memory.slot), + operationId, + }; + }), + ) + ).filter((batch): batch is MemoryRecallBatch => batch !== null); + const committed = applyMemoryRecallBatches({ + batches, + history: rawHistory, + state: prepared?.state, + }); + const projectedMessages = projectMemoryHistory({ + locks: activeLocks, + messages: committed.history, + }); + input.ctx.setVirtualContext(PendingMemoryCommitKey, { + history: committed.history, + projectedMessages, + state: committed.state, + }); + input.ctx.delete(PreparedMemoryCompactionKey); + return projectedMessages; +} + +export async function dispatchMemoryTurnCompleted(input: { + readonly abortSignal?: AbortSignal; + readonly ctx: AlsContext; + readonly event: TurnCompletedStreamEvent; + readonly memories: readonly ResolvedMemoryDefinition[]; + readonly messages: readonly ModelMessage[]; +}): Promise { + const locks = (input.ctx.get(TurnMemoryLocksKey) ?? {}) as Readonly< + Record + >; + const callbackContext = buildCallbackContext(); + const projected = projectMemoryHistory({ locks, messages: input.messages }); + await Promise.all( + [...input.memories] + .sort((left, right) => left.logicalPath.localeCompare(right.logicalPath)) + .map(async (memory) => { + const lock = locks[memory.slot]; + const capture = memory.provider.capture?.["turn.completed"]; + if (lock === undefined || capture === undefined) return; + await capture({ + ...callbackContext, + abortSignal: input.abortSignal ?? fallbackAbortSignal, + memory: { scope: lock.scope, slot: memory.slot }, + messages: projected, + operationId: memoryOperationId({ + phase: "turn.completed", + sequence: input.event.data.sequence, + sessionId: callbackContext.session.id, + slot: memory.slot, + turnId: input.event.data.turnId, + }), + turn: lock.turn, + }); + }), + ); +} + +async function resolveCompactionLocks(input: { + readonly abortSignal?: AbortSignal; + readonly appRoot: string; + readonly ctx: AlsContext; + readonly event: CompactionRequestedStreamEvent; + readonly memories: readonly ResolvedMemoryDefinition[]; + readonly nodeId: string; +}): Promise>> { + const existing = input.ctx.get(TurnMemoryLocksKey) as + | Readonly> + | undefined; + if (input.event.data.turnId.length > 0 && existing !== undefined) return existing; + const syntheticTurn = Object.freeze({ id: "", input: [], sequence: input.event.data.sequence }); + return Object.fromEntries( + ( + await Promise.all( + input.memories.map(async (memory) => { + const lock = await resolveMemoryLock({ + abortSignal: input.abortSignal ?? fallbackAbortSignal, + appRoot: input.appRoot, + ctx: input.ctx, + memory, + nodeId: input.nodeId, + turn: syntheticTurn, + }); + return lock === null ? null : ([memory.slot, lock] as const); + }), + ) + ).filter((entry): entry is readonly [string, InternalMemoryLock] => entry !== null), + ); +} + +function firstLockedTurn( + locks: Readonly>, +): InternalMemoryLock["turn"] | null { + return Object.values(locks)[0]?.turn ?? null; +} + +async function resolveMemoryLock(input: { + readonly abortSignal: AbortSignal; + readonly appRoot: string; + readonly ctx: AlsContext; + readonly memory: ResolvedMemoryDefinition; + readonly nodeId: string; + readonly turn: InternalMemoryLock["turn"]; +}): Promise { + const resolved = buildResolveContext(input.ctx, []); + const scopeContext: MemoryScopeContext = { + abortSignal: input.abortSignal, + channel: resolved.channel, + session: resolved.session, + }; + const scopeValue = + typeof input.memory.scope === "function" + ? await input.memory.scope(scopeContext) + : input.memory.scope; + if (scopeValue === null) { + reportDisabledMemorySlot(input.memory.slot, "scope"); + return null; + } + const namespaceContext = { + appRoot: input.appRoot, + node: input.nodeId, + slot: input.memory.slot, + }; + const namespaceValue = + input.memory.namespace === undefined + ? defaultNamespace(namespaceContext) + : typeof input.memory.namespace === "function" + ? await input.memory.namespace(namespaceContext) + : input.memory.namespace; + if (namespaceValue === null) { + reportDisabledMemorySlot(input.memory.slot, "namespace"); + return null; + } + return createMemoryLock({ + namespace: namespaceValue, + scope: scopeValue, + slot: input.memory.slot, + turn: input.turn, + visibility: input.memory.visibility, + }); +} + +function reportDisabledMemorySlot(slot: string, resolver: "namespace" | "scope"): void { + if (!isEveDevEnvironment()) return; + log.info("Memory slot is disabled for this operation", { resolver, slot }); +} + +export function memoryOperationId(input: { + readonly phase: string; + readonly sequence: number; + readonly sessionId: string; + readonly slot: string; + readonly turnId: string | null; +}): string { + return [ + "eve-memory-operation-v1", + input.sessionId, + String(input.sequence), + input.turnId ?? "standalone", + input.phase, + input.slot, + ].join(":"); +} diff --git a/packages/eve/src/context/memory-tools.test.ts b/packages/eve/src/context/memory-tools.test.ts new file mode 100644 index 0000000000..58ce00a187 --- /dev/null +++ b/packages/eve/src/context/memory-tools.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; + +import { buildDynamicTools } from "#context/build-dynamic-tools.js"; +import { ContextContainer, contextStorage } from "#context/container.js"; +import { + dispatchDynamicToolEvent, + rebindMissingCompiledDynamicToolCallbacks, +} from "#context/dynamic-tool-lifecycle.js"; +import { AuthKey, SessionIdKey, SessionKey, TurnMemoryLocksKey } from "#context/keys.js"; +import { createMemoryToolDynamicDefinition } from "#context/memory-tools.js"; +import { resolveApprovalPolicy } from "#approval/definition.js"; +import { defineTool } from "#tools/definition.js"; +import { defineMemory } from "#public/memory/index.js"; +import { always } from "#public/tools/approval/index.js"; +import type { ResolvedDynamicToolResolver } from "#runtime/types.js"; +import { createMemoryLock } from "#shared/memory-state.js"; + +const turn = Object.freeze({ id: "turn_0", input: [], sequence: 0 }); +const event = { data: { sequence: 0, turnId: "turn_0" }, type: "turn.started" as const }; + +function createContext(scope: string) { + const auth = { + attributes: {}, + authenticator: "test", + principalId: "user_1", + principalType: "user", + }; + const ctx = new ContextContainer(); + ctx.set(AuthKey, auth); + ctx.set(SessionIdKey, "session_1"); + ctx.set(SessionKey, { + auth: { current: auth, initiator: auth }, + sessionId: "session_1", + turn: { id: turn.id, sequence: turn.sequence }, + }); + ctx.set(TurnMemoryLocksKey, { + profile: createMemoryLock({ + namespace: "app", + scope, + slot: "profile", + turn, + visibility: "scope", + }), + }); + return ctx; +} + +function resolver(version: () => number): ResolvedDynamicToolResolver { + const definition = defineMemory({ + description: "Manage the profile.", + provider: { + recall: { "turn.started": async () => null }, + tools: async (context) => ({ + save: defineTool({ + approval: always(), + description: "Save a field.", + execute: async () => `${version()}:${String(context.memory.scope.value)}`, + inputSchema: {}, + }), + }), + }, + scope: "unused", + }); + const dynamic = createMemoryToolDynamicDefinition(definition, "profile"); + return { + eventNames: ["turn.started"], + events: dynamic.events as ResolvedDynamicToolResolver["events"], + logicalPath: "tools/profile.ts", + rebindMissingCallbacks: true, + slug: "profile", + sourceId: "eve:memory-wrapper:tools/profile.ts:from:application:memory/profile.ts", + sourceKind: "module", + }; +} + +describe("memory provider tools", () => { + it("qualifies provider tools, prepends the slot description, and rebinds latest code with the captured scope", async () => { + let deployedVersion = 1; + const ctx = createContext("user_1"); + const compiledResolver = resolver(() => deployedVersion); + + await contextStorage.run( + ctx, + async () => + await dispatchDynamicToolEvent({ + ctx, + event, + messages: [{ content: "hello", role: "user" }], + resolvers: [compiledResolver], + }), + ); + const [first] = buildDynamicTools(ctx); + expect(first).toMatchObject({ + description: "Manage the profile.\n\nSave a field.", + name: "profile__save", + }); + + const registry = Reflect.get(globalThis, Symbol.for("eve:dynamic-tool-callbacks")) as Map< + string, + Map + >; + registry.get("profile__save")?.clear(); + deployedVersion = 2; + ctx.set(TurnMemoryLocksKey, createContext("user_2").require(TurnMemoryLocksKey)); + + await rebindMissingCompiledDynamicToolCallbacks({ + ctx, + event, + messages: [{ content: "new turn", role: "user" }], + resolvers: [compiledResolver], + }); + + const [replayed] = buildDynamicTools(ctx); + if (replayed?.execute === undefined) throw new Error("Expected replayed execute callback."); + if (replayed.approval === undefined) throw new Error("Expected replayed approval callback."); + await expect( + resolveApprovalPolicy(replayed.approval)({ + approvedTools: new Set(), + callId: "call_1", + session: { + auth: { current: null, initiator: null }, + id: "session_1", + turn: { id: "turn_0", sequence: 0 }, + }, + toolName: "profile__save", + } as never), + ).resolves.toBe("user-approval"); + const output = await contextStorage.run( + ctx, + async () => await replayed.execute!({}, { messages: [], toolCallId: "call_1" }), + ); + expect(output).toBe("2:user_1"); + }); + + it("omits tools for a disabled slot or tools:false", async () => { + const ctx = createContext("user_1"); + const definition = defineMemory({ + provider: { + recall: { "turn.started": async () => null }, + tools: async () => ({ + save: defineTool({ description: "Save.", execute: async () => null, inputSchema: {} }), + }), + }, + scope: "unused", + tools: false, + }); + const dynamic = createMemoryToolDynamicDefinition(definition, "profile"); + const result = await contextStorage.run( + ctx, + async () => + await dynamic.events["turn.started"]?.(event, { + channel: {}, + messages: [], + session: { auth: { current: null, initiator: null }, id: "session_1" }, + }), + ); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/eve/src/context/memory-tools.ts b/packages/eve/src/context/memory-tools.ts new file mode 100644 index 0000000000..08fbd18aa6 --- /dev/null +++ b/packages/eve/src/context/memory-tools.ts @@ -0,0 +1,150 @@ +import type { MemoryDefinition, MemoryToolSet, MemoryToolsContext } from "#public/memory/index.js"; +import { resolveApprovalPolicy } from "#approval/definition.js"; +import { loadContext } from "#context/container.js"; +import { TurnMemoryLocksKey } from "#context/keys.js"; +import { TOOL_SLUG_PATTERN } from "#discover/grammar.js"; +import { defineDynamic } from "#dynamic/definition.js"; +import { markDynamicCallbackRebind } from "#internal/dynamic-tool-rebind.js"; +import { parseJsonObject, type JsonObject } from "#shared/json.js"; +import { stampDurableDynamicToolCallbacks } from "#tools/durable-callbacks.js"; +import { isBrandedToolEntry } from "#tools/dynamic.js"; + +export function createMemoryToolDynamicDefinition(definition: MemoryDefinition, slot: string) { + return markDynamicCallbackRebind( + defineDynamic({ + events: { + "turn.started": async (_event, resolveContext) => { + const lock = loadContext().get(TurnMemoryLocksKey)?.[slot]; + if ( + lock === undefined || + definition.tools === false || + definition.provider.tools === undefined + ) { + return null; + } + const context: MemoryToolsContext = { + ...resolveContext, + memory: { scope: lock.scope, slot }, + turn: lock.turn, + }; + const result = await definition.provider.tools(context); + if (result === null) return null; + if (typeof result !== "object" || Array.isArray(result)) { + throw new Error( + `Memory slot "${slot}" provider.tools() must return a tool map or null.`, + ); + } + return Object.fromEntries( + Object.entries(result).map(([key, tool]) => { + const name = `${slot}__${key}`; + if (!TOOL_SLUG_PATTERN.test(name)) { + throw new Error( + `Memory provider tool name "${name}" must start with an ASCII letter, contain only letters, digits, underscores, or dashes, and be at most 64 characters.`, + ); + } + if (!isBrandedToolEntry(tool)) { + throw new Error( + `Memory provider tool "${name}" must be created with defineTool().`, + ); + } + const description = + definition.description === undefined + ? tool.description + : `${definition.description}\n\n${tool.description}`; + const qualified = { ...tool, description }; + stampDurableDynamicToolCallbacks( + qualified, + createProviderToolCallbacks({ context, definition, key, tool }), + ); + return [name, qualified]; + }), + ); + }, + }, + }), + ); +} + +function createProviderToolCallbacks(input: { + readonly context: MemoryToolsContext; + readonly definition: MemoryDefinition; + readonly key: string; + readonly tool: MemoryToolSet[string]; +}) { + const closure = parseJsonObject({ context: input.context, key: input.key }); + const loadTool = async (rawClosure: JsonObject) => { + const key = rawClosure.key; + const context = rawClosure.context; + if (typeof key !== "string" || typeof context !== "object" || context === null) { + throw new Error("Memory provider tool callback has an invalid durable closure."); + } + const tools = await input.definition.provider.tools?.(readMemoryToolsContext(context)); + const tool = tools?.[key]; + if (tool === undefined || !isBrandedToolEntry(tool)) { + throw new Error(`Memory provider tool "${key}" was removed or renamed.`); + } + return tool; + }; + const callbacks: Parameters[1] = { + execute: { + callback: async (rawClosure, toolInput, context) => + await (await loadTool(rawClosure)).execute(toolInput, context), + closure, + }, + }; + if (input.tool.approval !== undefined) { + callbacks.approvalRequest = { + callback: async (rawClosure, context) => + await resolveApprovalPolicy((await loadTool(rawClosure)).approval!)(context), + closure, + }; + if (typeof input.tool.approval !== "function" && input.tool.approval.response !== undefined) { + callbacks.approvalResponse = { + callback: async (rawClosure, context) => { + const approval = (await loadTool(rawClosure)).approval; + if ( + approval === undefined || + typeof approval === "function" || + approval.response === undefined + ) { + throw new Error(`Memory provider tool "${input.key}" approval response was removed.`); + } + return await approval.response(context); + }, + closure, + }; + } + } + if (input.tool.toModelOutput !== undefined) { + callbacks.toModelOutput = { + callback: async (rawClosure, output) => + await ( + await loadTool(rawClosure) + ).toModelOutput!(output), + closure, + }; + } + return callbacks; +} + +function readMemoryToolsContext(value: unknown): MemoryToolsContext { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Memory provider tool callback has an invalid durable context."); + } + const candidate: unknown = value; + const context = candidate as MemoryToolsContext; + if ( + typeof context.channel !== "object" || + context.channel === null || + typeof context.memory !== "object" || + context.memory === null || + !Array.isArray(context.messages) || + typeof context.session !== "object" || + context.session === null || + typeof context.turn !== "object" || + context.turn === null + ) { + throw new Error("Memory provider tool callback has an invalid durable context."); + } + return context; +} diff --git a/packages/eve/src/discover/agent.integration.test.ts b/packages/eve/src/discover/agent.integration.test.ts index 349cb61999..0325b696a7 100644 --- a/packages/eve/src/discover/agent.integration.test.ts +++ b/packages/eve/src/discover/agent.integration.test.ts @@ -13,6 +13,7 @@ import { discoverAgent } from "#discover/discover-agent.js"; import { DISCOVER_EXTENSION_CAPABILITY_INCOMPATIBLE, DISCOVER_EXTENSION_COMPATIBILITY_INVALID, + DISCOVER_EXTENSION_MEMORY_UNSUPPORTED, DISCOVER_EXTENSION_MOUNT_AMBIGUOUS, DISCOVER_EXTENSION_MOUNT_MISSING_DECLARATION, DISCOVER_EXTENSION_NESTED_MOUNT_UNSUPPORTED, @@ -29,6 +30,7 @@ import { DISCOVER_UNSUPPORTED_DIRECTORY, } from "#discover/grammar.js"; import { DISCOVER_LIB_DIRECTORY_INVALID, DISCOVER_LIB_ENTRY_UNSUPPORTED } from "#discover/lib.js"; +import { DISCOVER_MEMORY_NAME_INVALID, DISCOVER_MEMORY_SLOT_AMBIGUOUS } from "#discover/memory.js"; import { DISCOVER_SCHEDULE_FILE_UNSUPPORTED, DISCOVER_SCHEDULES_DIRECTORY_INVALID, @@ -52,6 +54,76 @@ const EXTENSION_COMPATIBILITY_MANIFEST = JSON.stringify({ * here against an in-memory {@link buildMemoryAgentProject} tree. */ describe("discoverAgent (memory)", () => { + it("discovers flat and named memory slots with path-derived identities", async () => { + const flat = buildMemoryAgentProject({ + agentFiles: { "instructions.md": "Remember.", "memory.ts": "export default {};" }, + }); + const named = buildMemoryAgentProject({ + agentFiles: { + "instructions.md": "Remember.", + "memory/profile.ts": "export default {};", + "memory/workspace.mjs": "export default {};", + }, + }); + + const flatResult = await discoverAgent({ + agentRoot: flat.agentRoot, + appRoot: flat.appRoot, + source: flat.source, + }); + const namedResult = await discoverAgent({ + agentRoot: named.agentRoot, + appRoot: named.appRoot, + source: named.source, + }); + + expect(flatResult.diagnostics).toEqual([]); + expect(flatResult.manifest.memories).toEqual([ + { + logicalPath: "memory.ts", + slot: "memory", + sourceId: "memory.ts", + sourceKind: "module", + }, + ]); + expect(namedResult.diagnostics).toEqual([]); + expect(namedResult.manifest.memories).toEqual([ + { + logicalPath: "memory/profile.ts", + slot: "profile", + sourceId: "memory/profile.ts", + sourceKind: "module", + }, + { + logicalPath: "memory/workspace.mjs", + slot: "workspace", + sourceId: "memory/workspace.mjs", + sourceKind: "module", + }, + ]); + }); + + it("rejects mixed flat/directory memory and invalid slot names", async () => { + const project = buildMemoryAgentProject({ + agentFiles: { + "instructions.md": "Remember.", + "memory.ts": "export default {};", + "memory/not.valid.ts": "export default {};", + }, + }); + + const result = await discoverAgent({ + agentRoot: project.agentRoot, + appRoot: project.appRoot, + source: project.source, + }); + + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual( + expect.arrayContaining([DISCOVER_MEMORY_NAME_INVALID, DISCOVER_MEMORY_SLOT_AMBIGUOUS]), + ); + expect(result.manifest.memories).toEqual([]); + }); + it("discovers single-file schedules in both module and markdown forms with recursive nesting", async () => { const project = buildMemoryAgentProject({ agentFiles: { @@ -840,6 +912,34 @@ describe("discoverAgent (memory)", () => { ); }); + it("rejects memory declared by a mounted extension", async () => { + const project = buildMemoryAgentProject({ + appFiles: { + "node_modules/@acme/crm/package.json": JSON.stringify({ + name: "@acme/crm", + eve: { extension: { source: "source", dist: "extension" } }, + }), + "node_modules/@acme/crm/extension/_manifest.json": EXTENSION_COMPATIBILITY_MANIFEST, + "node_modules/@acme/crm/extension/memory.ts": "export default {};", + }, + agentFiles: { + "extensions/crm.ts": 'export { default } from "@acme/crm";\n', + "instructions.md": "You are a precise assistant.", + }, + }); + + const result = await discoverAgent({ + agentRoot: project.agentRoot, + appRoot: project.appRoot, + source: project.source, + }); + + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain( + DISCOVER_EXTENSION_MEMORY_UNSUPPORTED, + ); + expect(result.manifest.resolvedExtensions[0]?.manifest.memories).toEqual([]); + }); + it("reports an unresolved mount when the extension package is missing", async () => { const project = buildMemoryAgentProject({ agentFiles: { diff --git a/packages/eve/src/discover/discover-agent.ts b/packages/eve/src/discover/discover-agent.ts index 8b77d3fda8..4b49d65940 100644 --- a/packages/eve/src/discover/discover-agent.ts +++ b/packages/eve/src/discover/discover-agent.ts @@ -7,6 +7,7 @@ import { DISCOVER_EXTENSION_AGENT_CONFIG_UNSUPPORTED, DISCOVER_EXTENSION_MOUNT_AMBIGUOUS, DISCOVER_EXTENSION_MOUNT_MISSING_DECLARATION, + DISCOVER_EXTENSION_MEMORY_UNSUPPORTED, DISCOVER_EXTENSION_NESTED_MOUNT_UNSUPPORTED, DISCOVER_EXTENSION_SANDBOX_UNSUPPORTED, locateExtensionMount, @@ -29,6 +30,7 @@ import { readSortedDirectoryEntries, } from "#discover/grammar.js"; import { discoverLibSources } from "#discover/lib.js"; +import { discoverMemorySources } from "#discover/memory.js"; import { type AgentSourceManifest, type CreateAgentSourceManifestInput, @@ -156,6 +158,13 @@ export async function discoverAgent(input: DiscoverAgentInput): Promise descriptor.mountRef), resolvedExtensions, hooks: hooksResult.sources, + memories: role === "extension" ? [] : memoryResult.memories, lib: libResult.lib, instructions: instructionsResult.instructions, sandbox: sandboxResult.sandbox, diff --git a/packages/eve/src/discover/discover-subagent.ts b/packages/eve/src/discover/discover-subagent.ts index 4a1e2fb983..38058e7924 100644 --- a/packages/eve/src/discover/discover-subagent.ts +++ b/packages/eve/src/discover/discover-subagent.ts @@ -23,6 +23,7 @@ import { } from "#discover/grammar.js"; import { DISCOVER_HOOKS_DIRECTORY_INVALID } from "#discover/grammar.js"; import { discoverLibSources } from "#discover/lib.js"; +import { discoverMemorySources } from "#discover/memory.js"; import { type CreateAgentSourceManifestInput, createAgentSourceManifest, @@ -231,6 +232,13 @@ async function discoverLocalSubagentPackage(input: { }); diagnostics.push(...connectionsResult.diagnostics); + const memoryResult = await discoverMemorySources({ + rootEntries, + rootPath: input.subagentRoot, + source: input.source, + }); + diagnostics.push(...memoryResult.diagnostics); + const sandboxResult = await discoverSandboxSource({ rootEntries, rootPath: input.subagentRoot, @@ -305,6 +313,7 @@ async function discoverLocalSubagentPackage(input: { extensions: extensionsResult.mounts.map((mount) => mount.mountRef), resolvedExtensions: resolvedExtensions.mounts, hooks: hooksResult.sources, + memories: memoryResult.memories, lib: libResult.lib, instructions: instructionsResult.instructions, sandbox: sandboxResult.sandbox, diff --git a/packages/eve/src/discover/extensions.ts b/packages/eve/src/discover/extensions.ts index 6f69f4af24..4b2b13c601 100644 --- a/packages/eve/src/discover/extensions.ts +++ b/packages/eve/src/discover/extensions.ts @@ -68,6 +68,7 @@ export const DISCOVER_EXTENSION_AGENT_CONFIG_UNSUPPORTED = * consuming agent's to own. */ export const DISCOVER_EXTENSION_SANDBOX_UNSUPPORTED = "discover/extension-sandbox-unsupported"; +export const DISCOVER_EXTENSION_MEMORY_UNSUPPORTED = "discover/extension-memory-unsupported"; /** * Resolved on-disk location of one mounted extension package. diff --git a/packages/eve/src/discover/filesystem.ts b/packages/eve/src/discover/filesystem.ts index 2e96b3d04b..5b57b744af 100644 --- a/packages/eve/src/discover/filesystem.ts +++ b/packages/eve/src/discover/filesystem.ts @@ -49,6 +49,8 @@ export type AgentRootEntryKind = | "instructions-markdown" | "instructions-module" | "lib-directory" + | "memory-directory" + | "memory-module" | "sandbox-directory" | "schedules-directory" | "skills-directory" @@ -72,6 +74,8 @@ export type LocalSubagentEntryKind = | "instructions-module" | "invalid-schedules-directory" | "lib-directory" + | "memory-directory" + | "memory-module" | "sandbox-directory" | "skills-directory" | "system-markdown" @@ -144,6 +148,8 @@ export function classifyAgentRootEntry( return "instructions-module"; } + if (matchesSupportedModuleBaseName(name, "memory")) return "memory-module"; + if (name.toLowerCase() === "system.md") { return "system-markdown"; } @@ -188,6 +194,8 @@ export function classifyAgentRootEntry( return "lib-directory"; } + if (name === "memory") return "memory-directory"; + if (name === "skills") { return "skills-directory"; } @@ -232,6 +240,8 @@ export function classifyLocalSubagentEntry( return "instructions-module"; } + if (matchesSupportedModuleBaseName(name, "memory")) return "memory-module"; + if (name.toLowerCase() === "system.md") { return "system-markdown"; } @@ -268,6 +278,8 @@ export function classifyLocalSubagentEntry( return "lib-directory"; } + if (name === "memory") return "memory-directory"; + if (name === "sandbox") { return "sandbox-directory"; } diff --git a/packages/eve/src/discover/manifest.ts b/packages/eve/src/discover/manifest.ts index dfef9a8fc5..ebc99c66d3 100644 --- a/packages/eve/src/discover/manifest.ts +++ b/packages/eve/src/discover/manifest.ts @@ -20,7 +20,7 @@ export const AGENT_SOURCE_MANIFEST_KIND = "eve-agent-discovery-manifest"; /** * Current manifest schema version. */ -export const AGENT_SOURCE_MANIFEST_VERSION = 14; +export const AGENT_SOURCE_MANIFEST_VERSION = 15; /** * Channel source reference preserved by the discovery manifest. @@ -66,6 +66,10 @@ export type SkillSourceRef = */ export type ToolSourceRef = ModuleSourceRef; +export interface MemorySourceRef extends ModuleSourceRef { + readonly slot: string; +} + /** * Recursive manifest entry for a local subagent package. */ @@ -202,6 +206,7 @@ export interface AgentSourceManifest { */ resolvedExtensions: ResolvedExtensionMount[]; hooks: ModuleSourceRef[]; + memories: MemorySourceRef[]; lib: LibSourceRef[]; kind: typeof AGENT_SOURCE_MANIFEST_KIND; /** @@ -250,6 +255,7 @@ export interface CreateAgentSourceManifestInput { extensions?: readonly ExtensionSourceRef[]; resolvedExtensions?: readonly ResolvedExtensionMount[]; hooks?: readonly ModuleSourceRef[]; + memories?: readonly MemorySourceRef[]; lib?: readonly LibSourceRef[]; /** * Optional package name read from the app root's package.json. @@ -326,6 +332,7 @@ export function createAgentSourceManifest( extensions: [...(input.extensions ?? [])], resolvedExtensions: [...(input.resolvedExtensions ?? [])], hooks: [...(input.hooks ?? [])], + memories: [...(input.memories ?? [])], instructions: [...(input.instructions ?? [])], lib: [...(input.lib ?? [])], kind: AGENT_SOURCE_MANIFEST_KIND, diff --git a/packages/eve/src/discover/memory.ts b/packages/eve/src/discover/memory.ts new file mode 100644 index 0000000000..a51682f620 --- /dev/null +++ b/packages/eve/src/discover/memory.ts @@ -0,0 +1,81 @@ +import { join } from "node:path"; + +import { createDiscoverErrorDiagnostic, type DiscoverDiagnostic } from "#discover/diagnostics.js"; +import { matchesSupportedModuleBaseName, stripLogicalPathExtension } from "#discover/filesystem.js"; +import { TOOL_SLUG_PATTERN, discoverFlatModuleSource } from "#discover/grammar.js"; +import type { MemorySourceRef } from "#discover/manifest.js"; +import { discoverNamedSourceDirectory } from "#discover/named-source-directory.js"; +import type { ProjectSource, ProjectSourceEntry } from "#discover/project-source.js"; + +export const DISCOVER_MEMORY_DIRECTORY_INVALID = "discover/memory-directory-invalid"; +export const DISCOVER_MEMORY_SLOT_AMBIGUOUS = "discover/memory-slot-ambiguous"; +export const DISCOVER_MEMORY_NAME_INVALID = "discover/memory-name-invalid"; + +export async function discoverMemorySources(input: { + readonly rootEntries: readonly ProjectSourceEntry[]; + readonly rootPath: string; + readonly source: ProjectSource; +}): Promise<{ readonly diagnostics: DiscoverDiagnostic[]; readonly memories: MemorySourceRef[] }> { + const flat = discoverFlatModuleSource({ + rootEntries: input.rootEntries, + rootPath: input.rootPath, + slotName: "memory", + }); + const hasFlatModule = input.rootEntries.some( + (entry) => entry.isFile() && matchesSupportedModuleBaseName(entry.name, "memory"), + ); + const hasDirectory = input.rootEntries.some( + (entry) => entry.name === "memory" && entry.isDirectory(), + ); + const directory = await discoverNamedSourceDirectory({ + directoryName: "memory", + invalidDirectoryCode: DISCOVER_MEMORY_DIRECTORY_INVALID, + invalidDirectoryMessage: `Expected "${join(input.rootPath, "memory")}" to be a directory of authored memory slots.`, + recursive: false, + rootEntries: input.rootEntries, + rootPath: input.rootPath, + source: input.source, + validateSegment: createMemoryNameDiagnostic, + }); + + if (hasFlatModule && hasDirectory) { + return { + diagnostics: [ + ...flat.diagnostics, + ...directory.diagnostics, + createDiscoverErrorDiagnostic({ + code: DISCOVER_MEMORY_SLOT_AMBIGUOUS, + message: + 'Memory must use either the flat "memory.ts" form or the named "memory/" directory form, not both.', + sourcePath: input.rootPath, + }), + ], + memories: [], + }; + } + if (flat.module !== undefined) { + return { + diagnostics: [...flat.diagnostics, ...directory.diagnostics], + memories: [{ ...flat.module, slot: "memory" }], + }; + } + return { + diagnostics: [...flat.diagnostics, ...directory.diagnostics], + memories: directory.sources.map((source) => ({ + ...source, + slot: stripLogicalPathExtension(source.logicalPath).slice("memory/".length), + })), + }; +} + +function createMemoryNameDiagnostic( + segment: string, + sourcePath: string, +): DiscoverDiagnostic | null { + if (TOOL_SLUG_PATTERN.test(segment)) return null; + return createDiscoverErrorDiagnostic({ + code: DISCOVER_MEMORY_NAME_INVALID, + message: `Memory slot "${segment}" must start with an ASCII letter and contain only letters, digits, underscores, or dashes (64 characters maximum).`, + sourcePath, + }); +} diff --git a/packages/eve/src/discover/project.ts b/packages/eve/src/discover/project.ts index 66ff6876b9..162fb1bcd3 100644 --- a/packages/eve/src/discover/project.ts +++ b/packages/eve/src/discover/project.ts @@ -161,7 +161,10 @@ async function isFlatAgentRoot(source: ProjectSource, directoryPath: string): Pr return Array.from(entries.entries()).some(([name, entryType]) => { const entryKind = classifyAgentRootEntry(name, entryType); return ( - entryKind !== "unknown" && entryKind !== "ignored-directory" && entryKind !== "lib-directory" + entryKind !== "unknown" && + entryKind !== "ignored-directory" && + entryKind !== "lib-directory" && + entryKind !== "memory-directory" ); }); } diff --git a/packages/eve/src/discover/subagent.integration.test.ts b/packages/eve/src/discover/subagent.integration.test.ts index 232445e98f..303960d7a0 100644 --- a/packages/eve/src/discover/subagent.integration.test.ts +++ b/packages/eve/src/discover/subagent.integration.test.ts @@ -19,6 +19,8 @@ describe("discoverSubagents (memory)", () => { 'throw new Error("local subagent modules should not execute during discovery");\n', "subagents/researcher/lib/client.js": 'throw new Error("subagent lib modules should not execute during discovery");\n', + "subagents/researcher/memory/profile.ts": + 'throw new Error("subagent memory modules should not execute during discovery");\n', "subagents/researcher/sandbox/sandbox.js": 'throw new Error("subagent sandboxes should not execute during discovery");\n', "subagents/researcher/subagents/reviewer/agent.js": @@ -75,6 +77,14 @@ describe("discoverSubagents (memory)", () => { sourceId: "instructions.md", }, ], + memories: [ + { + logicalPath: "memory/profile.ts", + slot: "profile", + sourceId: "memory/profile.ts", + sourceKind: "module", + }, + ], sandbox: { sourceKind: "module", logicalPath: "sandbox/sandbox.js", @@ -92,7 +102,7 @@ describe("discoverSubagents (memory)", () => { sourceId: "tools/search.js", }, ], - version: 14, + version: 15, }, rootPath: researcherRoot, sourceId: "subagents/researcher", @@ -138,7 +148,7 @@ describe("discoverSubagents (memory)", () => { logicalPath: "agent.js", sourceId: "agent.js", }, - version: 14, + version: 15, }, rootPath: reviewerRoot, sourceId: "subagents/reviewer", diff --git a/packages/eve/src/evals/target.ts b/packages/eve/src/evals/target.ts index bd42eca01c..920bd68b1d 100644 --- a/packages/eve/src/evals/target.ts +++ b/packages/eve/src/evals/target.ts @@ -189,7 +189,7 @@ async function waitForTargetHealth(client: Client, url: string): Promise { } function assertAgentInfoShape(info: AgentInfoResult, url: string): void { - if (info.kind !== "eve-agent-info" || info.version !== 3) { + if (info.kind !== "eve-agent-info" || info.version !== 4) { throw new Error(`Eval target ${url} returned an unrecognized /eve/v1/info payload.`); } } diff --git a/packages/eve/src/execution/history-view.ts b/packages/eve/src/execution/history-view.ts index a078f4a609..70cbe1ef06 100644 --- a/packages/eve/src/execution/history-view.ts +++ b/packages/eve/src/execution/history-view.ts @@ -1,19 +1,20 @@ import type { HarnessSession } from "#harness/types.js"; import { createHistoryViewPreparer, - identityHistoryViewProjector, + type HistoryViewProjector, type PreparedHistoryView, } from "#shared/history-view.js"; +import { projectMemoryHistoryFromSessionState } from "#shared/memory-state.js"; export interface ExecutionHistoryView { readonly initial: PreparedHistoryView; readonly messages: (session: HarnessSession) => PreparedHistoryView["messages"]; readonly prepare: (session: HarnessSession) => PreparedHistoryView; - readonly projector: typeof identityHistoryViewProjector; + readonly projector: HistoryViewProjector; } export function createExecutionHistoryView(session: HarnessSession): ExecutionHistoryView { - const projector = identityHistoryViewProjector; + const projector: HistoryViewProjector = projectMemoryHistoryFromSessionState; const prepareHistory = createHistoryViewPreparer({ projector }); const prepare = (next: HarnessSession) => prepareHistory(next.history, next.state); diff --git a/packages/eve/src/execution/workflow-steps.ts b/packages/eve/src/execution/workflow-steps.ts index 2dca90c28e..aa514eb17c 100644 --- a/packages/eve/src/execution/workflow-steps.ts +++ b/packages/eve/src/execution/workflow-steps.ts @@ -17,8 +17,10 @@ import { } from "#context/dynamic-subagent-lifecycle.js"; import { dispatchDynamicToolEvent, + rebindMissingCompiledDynamicToolCallbacks, refreshDynamicSessionToolsForRuntimeRevision, } from "#context/dynamic-tool-lifecycle.js"; +import { dispatchMemoryLifecycleEvent } from "#context/memory-event-lifecycle.js"; import { AuthKey, CapabilitiesKey, @@ -65,6 +67,7 @@ import { derivePendingState } from "#execution/pending-turn-state.js"; import { createAuthorizationCompletedEvent, createSessionStartedEvent, + createTurnStartedEvent, encodeMessageStreamEvent, type UnstampedMessageStreamEvent, stampMessageStreamEvent, @@ -111,9 +114,7 @@ const TASK_DONE_WITH_PENDING_INPUT_ERROR_MESSAGE = export type { TurnStepInput }; -/** - * Runs one atomic harness step inside a durable `"use step"` boundary. - */ +/** Runs one atomic harness step inside a durable `"use step"` boundary. */ export async function turnStep(rawInput: TurnStepInput): Promise { "use step"; @@ -153,7 +154,6 @@ export async function turnStep(rawInput: TurnStepInput): Promise["matches"] | undefined; if (pendingAuth && input.input?.kind === "deliver") { @@ -354,6 +354,15 @@ export async function turnStep(rawInput: TurnStepInput): Promise => { const toEmit = await callAdapterEventHandler(adapter, event, adapterCtx); setChannelContext(ctx, { ...adapter, state: { ...adapterCtx.state } }); @@ -380,13 +389,22 @@ export async function turnStep(rawInput: TurnStepInput): Promise>> { + const slot = context.parameters.slot; + const logicalPath = context.parameters.memoryLogicalPath; + const exportName = context.parameters.memoryExportName; + const dependency = context.dependencies.memory; + if ( + typeof slot !== "string" || + typeof logicalPath !== "string" || + typeof exportName !== "string" || + dependency === undefined + ) { + throw new Error("The compiled memory wrapper binding is missing its selected memory source."); + } + const value = await materializeAuthoredModuleExport( + getAuthoredModuleExport(dependency, { exportName, logicalPath }), + ); + const definition = normalizeMemoryDefinition( + value, + `Expected the memory export "${exportName}" from "${logicalPath}" to be created with defineMemory().`, + ); + return { default: createMemoryToolDynamicDefinition(definition, slot) }; +} diff --git a/packages/eve/src/framework/sources/registry.ts b/packages/eve/src/framework/sources/registry.ts index 495c838a82..d205bd1fa3 100644 --- a/packages/eve/src/framework/sources/registry.ts +++ b/packages/eve/src/framework/sources/registry.ts @@ -3,6 +3,7 @@ import { createAgentSourceRegistry, defineProgrammaticAgentSource, loadProgrammaticModuleNamespace, + memoizeModuleNamespaceFactories, type AgentSourceRegistry, type AgentModuleBacking, type ProgrammaticModuleNamespace, @@ -86,18 +87,42 @@ const rootDefaults = defineProgrammaticAgentSource({ ], }); -export const frameworkAgentSourceRegistry: AgentSourceRegistry = createAgentSourceRegistry([ - { applyTo: "all-local-nodes", source: localDefaults }, - { applyTo: "root", source: rootDefaults }, -]); +const memoryWrapperTemplateSource = defineProgrammaticAgentSource({ + id: "eve:memory-wrapper", + revision, + modules: [ + { + logicalPath: "tools/memory-wrapper.ts", + loadNamespace: async (context) => { + const { loadMemoryWrapperNamespace } = + await import("#framework/sources/modules/memory-wrapper.js"); + return await loadMemoryWrapperNamespace(context); + }, + }, + ], +}); + +export const frameworkAgentSourceRegistry: AgentSourceRegistry = createAgentSourceRegistry( + [ + { applyTo: "all-local-nodes", source: localDefaults }, + { applyTo: "root", source: rootDefaults }, + ], + { templates: [memoryWrapperTemplateSource] }, +); + +export const memoryWrapperTemplate = frameworkAgentSourceRegistry.templates.get( + memoryWrapperTemplateSource.id, +)!; export async function loadFrameworkProgrammaticModule( backing: Extract, dependencyNamespaces?: Readonly>, ): Promise { - return await loadProgrammaticModuleNamespace({ - backing, - dependencyNamespaces, - registries: [frameworkAgentSourceRegistry], - }); + return memoizeModuleNamespaceFactories( + await loadProgrammaticModuleNamespace({ + backing, + dependencyNamespaces, + registries: [frameworkAgentSourceRegistry], + }), + ); } diff --git a/packages/eve/src/harness/emission.ts b/packages/eve/src/harness/emission.ts index c30333c40b..21337b9020 100644 --- a/packages/eve/src/harness/emission.ts +++ b/packages/eve/src/harness/emission.ts @@ -214,12 +214,14 @@ export async function emitTurnEpilogue( emitFn: HarnessEmitFn, state: HarnessEmissionState, mode: RunMode, + messages?: readonly import("ai").ModelMessage[], ): Promise { await emitFn( createTurnCompletedEvent({ sequence: state.sequence, turnId: state.turnId, }), + messages, ); if (mode === "conversation") { diff --git a/packages/eve/src/harness/tool-loop-compaction-accounting.test.ts b/packages/eve/src/harness/tool-loop-compaction-accounting.test.ts index 08a73dd1b5..10b9a44713 100644 --- a/packages/eve/src/harness/tool-loop-compaction-accounting.test.ts +++ b/packages/eve/src/harness/tool-loop-compaction-accounting.test.ts @@ -4,6 +4,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { appendPendingInputBatch } from "#harness/input-requests.js"; import { createToolLoopHarness } from "#harness/tool-loop.js"; import type { HarnessSession, StepFn, StepNext, ToolLoopHarnessConfig } from "#harness/types.js"; +import { + applyMemoryRecallBatches, + createMemoryLock, + projectMemoryHistoryFromSessionState, + validateMemoryRecallResult, +} from "#shared/memory-state.js"; vi.mock("ai", () => ({ generateText: vi.fn(), @@ -122,6 +128,67 @@ function expectStepFn(value: StepNext): StepFn { } describe("tool-loop structured compaction accounting", () => { + it("keeps private memory out of the summary while retaining its attributed record", async () => { + vi.mocked(generateText).mockResolvedValue({ + text: "ordinary summary", + } as Awaited>); + setupMockAgentSequence([ + { + finishReason: "stop", + response: { messages: [{ content: "Done.", role: "assistant" }] }, + text: "Done.", + toolCalls: [], + toolResults: [], + }, + ]); + const memoryLock = createMemoryLock({ + namespace: "app", + scope: "user_1", + slot: "profile", + turn: { id: "turn_0", input: [], sequence: 0 }, + visibility: "scope", + }); + const recalled = applyMemoryRecallBatches({ + batches: [ + { + lock: memoryLock, + messages: validateMemoryRecallResult( + { messages: [{ content: "PRIVATE_MEMORY_SENTINEL", id: "profile" }] }, + "profile", + ), + operationId: "recall_1", + }, + ], + history: [], + state: undefined, + }); + const runStep = createToolLoopHarness( + createTestConfig({ + historyProjector: projectMemoryHistoryFromSessionState, + resolveModel: vi.fn().mockResolvedValue({ modelId: "test-model" } as LanguageModel), + }), + ); + + const result = await runStep( + createTestSession({ + compaction: { recentWindowSize: 0, threshold: 100 }, + history: [ + ...recalled.history, + { content: `ordinary ${"conversation ".repeat(100)}`, role: "user" }, + ], + state: recalled.state, + }), + { message: "continue" }, + ); + + expect(vi.mocked(generateText)).toHaveBeenCalledOnce(); + expect(vi.mocked(generateText).mock.calls[0]?.[0].prompt).not.toContain( + "PRIVATE_MEMORY_SENTINEL", + ); + expect(JSON.stringify(result.session.history)).toContain("PRIVATE_MEMORY_SENTINEL"); + expect(JSON.stringify(result.session.history)).toContain("eve.memory"); + }); + it("compacts before the continuation step when structured tool results were appended", async () => { vi.mocked(generateText).mockResolvedValue({ text: "summary", diff --git a/packages/eve/src/harness/tool-loop.test.ts b/packages/eve/src/harness/tool-loop.test.ts index a9d64b228f..ad22eeb392 100644 --- a/packages/eve/src/harness/tool-loop.test.ts +++ b/packages/eve/src/harness/tool-loop.test.ts @@ -1010,6 +1010,7 @@ describe("createToolLoopHarness", () => { const runStep = createToolLoopHarness( createTestConfig("conversation", undefined, { + historyProjector: ({ messages }) => [...messages], resolveModel: vi.fn().mockResolvedValue( new MockLanguageModelV3({ modelId: "claude-sonnet-4-5", diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index 9258353cdf..15035dc897 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -47,6 +47,11 @@ import { drainDynamicInstructionUserMessages, prepareDynamicInstructionPreamble, } from "#context/dynamic-instruction-lifecycle.js"; +import { + drainMemoryCommit, + prepareMemoryCompaction, + prepareMemoryPreamble, +} from "#context/memory-lifecycle.js"; import { getActiveDynamicModelSelection, isDynamicModelSelectionError, @@ -259,7 +264,12 @@ import { import { buildFinalOutputTool, FINAL_OUTPUT_TOOL_NAME } from "#harness/final-output.js"; import type { RuntimeModelReference } from "#runtime/agent/bootstrap.js"; import type { RunMode } from "#shared/run-mode.js"; -import { createHistoryViewPreparer } from "#shared/history-view.js"; +import { createHistoryViewPreparer, type HistoryViewProjector } from "#shared/history-view.js"; +import { + canonicalizeMemoryRecords, + clearMemorySessionState, + shouldCanonicalizeMemory, +} from "#shared/memory-state.js"; import { type CompactionConfig, type HarnessSession, @@ -760,6 +770,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { thresholdPercent: session.compaction.thresholdPercent, }, history: [], + state: clearMemorySessionState(session.state), }; await emit?.( createContextClearedEvent({ @@ -788,7 +799,8 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { turnId: activeTurnId(emissionState), }, force: true, - messages: [...projectHistory(session.history, session.state)], + historyProjector: config.historyProjector, + messages: [...session.history], model: resolvedModel.model, onCompaction: config.onCompaction, resolveModel: config.resolveModel, @@ -1134,13 +1146,34 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { // --- Turn preamble ------------------------------------------------------ + const preparedTurnInput: ModelMessage[] = []; + if (effectiveStepInput?.context !== undefined && pending.deferredContext !== true) { + for (const entry of effectiveStepInput.context) { + preparedTurnInput.push({ content: entry, role: "user" }); + } + } + const normalizedTurnContent = normalizeUserContent(effectiveStepInput?.message); + const stagedTurnContent = + normalizedTurnContent !== undefined && !pending.deferredMessage && !pending.consumedMessage + ? await stageAttachmentsToSandbox(normalizedTurnContent) + : undefined; + if (stagedTurnContent !== undefined) { + preparedTurnInput.push({ content: stagedTurnContent, role: "user" }); + } + let instructionMessages: ModelMessage[] = []; + let memoryCommit: ReturnType = undefined; if (emit && hasStepInput(input)) { if (store !== undefined) { prepareDynamicInstructionPreamble( store, projectHistory(pending.session.history, pending.session.state), ); + prepareMemoryPreamble(store, { + history: pending.messages, + input: preparedTurnInput, + state: pending.session.state, + }); } try { const traceContext = await preparePreambleTrace(); @@ -1153,9 +1186,11 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { ); } catch (error) { instructionMessages = store === undefined ? [] : drainDynamicInstructionUserMessages(store); + memoryCommit = store === undefined ? undefined : drainMemoryCommit(store); session = { ...pending.session, - history: [...pending.session.history, ...instructionMessages], + history: [...(memoryCommit?.history ?? pending.session.history), ...instructionMessages], + state: memoryCommit?.state ?? pending.session.state, }; if (!isDynamicModelSelectionError(error)) throw error; return failModelSelection(error, { @@ -1166,24 +1201,27 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { }); } instructionMessages = store === undefined ? [] : drainDynamicInstructionUserMessages(store); + memoryCommit = store === undefined ? undefined : drainMemoryCommit(store); if (turnSpan) { turnSpan.setAttribute("eve.turn.id", emissionState.turnId); } } + const committedHistory = memoryCommit?.history ?? pending.session.history; const historyLength = pending.session.history.length; session = setHarnessEmissionState( { ...pending.session, - history: [...pending.session.history, ...instructionMessages], + history: [...committedHistory, ...instructionMessages], + state: memoryCommit?.state ?? pending.session.state, }, emissionState, ); let messages: ModelMessage[] = [ - ...pending.messages.slice(0, historyLength), + ...(memoryCommit?.history ?? pending.messages.slice(0, historyLength)), ...instructionMessages, - ...pending.messages.slice(historyLength), + ...(memoryCommit === undefined ? pending.messages.slice(historyLength) : []), ]; // A resolved session-limit continuation prompt grants a fresh token @@ -1221,22 +1259,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { } } - if (effectiveStepInput?.context !== undefined && pending.deferredContext !== true) { - for (const entry of effectiveStepInput.context) { - messages.push({ content: entry, role: "user" }); - } - } - - const userContent = normalizeUserContent(effectiveStepInput?.message); - if (userContent !== undefined && !pending.deferredMessage && !pending.consumedMessage) { - // Staging writes FilePart bytes into the sandbox and replaces - // each part's `data` with a compact `eve-sandbox:` URL. The - // `messages` array — and everything that flows into - // `session.history` from it — therefore never carries raw - // attachment bytes across step boundaries. - const content = await stageAttachmentsToSandbox(userContent); - messages.push({ content, role: "user" }); - } + messages = [...messages, ...preparedTurnInput]; let projectedMessages = projectHistory(messages, session.state); @@ -1285,7 +1308,8 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { auth: ctx?.get(AuthKey) ?? null, emit, emissionState, - messages: [...projectedMessages], + historyProjector: config.historyProjector, + messages: [...messages], model, onCompaction: config.onCompaction, resolveModel: config.resolveModel, @@ -2948,6 +2972,7 @@ async function emitStructuredResult( emissionState: ReturnType, structured: JsonValue, mode: RunMode, + messages: readonly ModelMessage[], ): Promise> { await emit( createResultCompletedEvent({ @@ -2957,7 +2982,7 @@ async function emitStructuredResult( turnId: emissionState.turnId, }), ); - return emitTurnEpilogue(emit, emissionState, mode); + return emitTurnEpilogue(emit, emissionState, mode, messages); } /** @@ -2979,7 +3004,7 @@ async function finishTaskTurn(input: { if (schema === undefined) { if (emit) { - emissionState = await emitTurnEpilogue(emit, emissionState, "task"); + emissionState = await emitTurnEpilogue(emit, emissionState, "task", session.history); session = setHarnessEmissionState(session, emissionState); } return { next: { done: true, output: stepOutput ?? "" }, session }; @@ -3003,7 +3028,13 @@ async function finishTaskTurn(input: { session = persistStructuredAssistantTurn(session, history, structured); if (emit) { - emissionState = await emitStructuredResult(emit, emissionState, structured, "task"); + emissionState = await emitStructuredResult( + emit, + emissionState, + structured, + "task", + session.history, + ); session = setHarnessEmissionState(session, emissionState); } return { next: { done: true, output: structured }, session }; @@ -3028,7 +3059,7 @@ async function finishConversationTurn(input: { if (schema === undefined) { if (emit) { - emissionState = await emitTurnEpilogue(emit, emissionState, "conversation"); + emissionState = await emitTurnEpilogue(emit, emissionState, "conversation", session.history); session = setHarnessEmissionState(session, emissionState); } const settledTurn = { output: stepOutput ?? "" } satisfies SettledTurn; @@ -3056,7 +3087,13 @@ async function finishConversationTurn(input: { session = persistStructuredAssistantTurn(session, history, structured); if (emit) { - emissionState = await emitStructuredResult(emit, emissionState, structured, "conversation"); + emissionState = await emitStructuredResult( + emit, + emissionState, + structured, + "conversation", + session.history, + ); session = setHarnessEmissionState(session, emissionState); } const settledTurn = { output: structured } satisfies SettledTurn; @@ -3260,6 +3297,7 @@ async function maybeCompact(input: { readonly emit?: ToolLoopHarnessConfig["handleEvent"]; readonly emissionState: ReturnType; readonly force?: boolean; + readonly historyProjector?: HistoryViewProjector; readonly messages: ModelMessage[]; readonly model: LanguageModel; readonly onCompaction?: ToolLoopHarnessConfig["onCompaction"]; @@ -3274,9 +3312,13 @@ async function maybeCompact(input: { }> { const { emit, emissionState } = input; let messages = input.messages; - const session = input.session; + let session = input.session; + const projectedMessages = + input.historyProjector?.({ messages, state: session.state }) ?? messages; + const needsSummary = input.force === true || shouldCompact(projectedMessages, session.compaction); + const needsMemoryCanonicalization = shouldCanonicalizeMemory(messages); - if (input.force !== true && !shouldCompact(messages, session.compaction)) { + if (!needsSummary && !needsMemoryCanonicalization) { return { compacted: false, messages, session }; } @@ -3295,27 +3337,39 @@ async function maybeCompact(input: { ) as Parameters[3]; if (emit) { + const ctx = contextStorage.getStore(); + if (ctx !== undefined) { + prepareMemoryCompaction(ctx, { history: messages, state: session.state }); + } await emit( createCompactionRequestedEvent({ modelId: formatLanguageModelGatewayId(compaction.model), sequence: emissionState.sequence, sessionId: session.sessionId, turnId: emissionState.turnId, - usageInputTokens: getInputTokenCount(messages, session.compaction), + usageInputTokens: getInputTokenCount(projectedMessages, session.compaction), }), + projectedMessages, ); } - messages = await compactMessages( - messages, - compaction.model, - session.compaction, - providerOptions, - input.telemetry, - buildGatewayAttributionHeaders(compaction.model, input.runtimeIdentity), - input.abortSignal, - input.force === true, - ); + const canonical = canonicalizeMemoryRecords(messages); + const ordinary = + input.historyProjector?.({ messages: canonical.ordinary, state: session.state }) ?? + canonical.ordinary; + const compactedOrdinary = needsSummary + ? await compactMessages( + [...ordinary], + compaction.model, + session.compaction, + providerOptions, + input.telemetry, + buildGatewayAttributionHeaders(compaction.model, input.runtimeIdentity), + input.abortSignal, + input.force === true, + ) + : [...ordinary]; + messages = [...canonical.memory, ...compactedOrdinary]; if (input.onCompaction) { for (const msg of input.onCompaction()) { @@ -3324,6 +3378,10 @@ async function maybeCompact(input: { } if (emit) { + const ctx = contextStorage.getStore(); + if (ctx !== undefined) { + prepareMemoryCompaction(ctx, { history: messages, state: session.state }); + } await emit( createCompactionCompletedEvent({ modelId: formatLanguageModelGatewayId(compaction.model), @@ -3331,7 +3389,15 @@ async function maybeCompact(input: { sessionId: session.sessionId, turnId: emissionState.turnId, }), + input.historyProjector?.({ messages, state: session.state }) ?? messages, ); + if (ctx !== undefined) { + const commit = drainMemoryCommit(ctx); + if (commit !== undefined) { + messages = [...commit.history]; + session = { ...session, state: commit.state }; + } + } } return { compacted: true, messages, session }; diff --git a/packages/eve/src/internal/authored-definition/memory.test.ts b/packages/eve/src/internal/authored-definition/memory.test.ts new file mode 100644 index 0000000000..a5882b311f --- /dev/null +++ b/packages/eve/src/internal/authored-definition/memory.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeMemoryDefinition } from "#internal/authored-definition/memory.js"; +import { defineMemory } from "#public/memory/index.js"; + +const message = "Invalid memory definition."; + +function validDefinition() { + return defineMemory({ + provider: { + capture: { + "compaction.requested": async () => {}, + "turn.completed": async () => {}, + }, + recall: { + "compaction.completed": async () => null, + "turn.started": async () => null, + }, + tools: async () => null, + }, + scope: "user_1", + }); +} + +describe("normalizeMemoryDefinition", () => { + it("accepts lifecycle-keyed recall and capture handlers", () => { + const definition = validDefinition(); + + expect(normalizeMemoryDefinition(definition, message)).toBe(definition); + }); + + it("rejects the former function-shaped recall contract", () => { + const definition = { + ...validDefinition(), + provider: { recall: async () => null }, + }; + + expect(() => normalizeMemoryDefinition(definition, message)).toThrow( + '"provider.recall" must be an object', + ); + }); + + it("rejects unknown lifecycle keys", () => { + const definition = validDefinition(); + const invalid = { + ...definition, + provider: { + ...definition.provider, + recall: { ...definition.provider.recall, "turn.completed": async () => null }, + }, + }; + + expect(() => normalizeMemoryDefinition(invalid, message)).toThrow("Unknown key"); + }); + + it("rejects non-function capture handlers", () => { + const definition = validDefinition(); + const invalid = { + ...definition, + provider: { + ...definition.provider, + capture: { "turn.completed": true }, + }, + }; + + expect(() => normalizeMemoryDefinition(invalid, message)).toThrow( + /provider\.capture\["turn\.completed"\].*must be a function/, + ); + }); +}); diff --git a/packages/eve/src/internal/authored-definition/memory.ts b/packages/eve/src/internal/authored-definition/memory.ts new file mode 100644 index 0000000000..08d715bbfe --- /dev/null +++ b/packages/eve/src/internal/authored-definition/memory.ts @@ -0,0 +1,87 @@ +import type { MemoryDefinition } from "#public/memory/index.js"; +import { expectObjectRecord, expectOnlyKnownKeys } from "#internal/authored-module.js"; +import { isMemoryDefinition } from "#shared/memory-definition.js"; + +export function normalizeMemoryDefinition(value: unknown, message: string): MemoryDefinition { + if (!isMemoryDefinition(value)) throw new Error(message); + const record = expectObjectRecord(value, message); + expectOnlyKnownKeys( + record, + ["description", "namespace", "provider", "scope", "tools", "visibility"], + message, + ); + if ( + record.description !== undefined && + (typeof record.description !== "string" || record.description.trim().length === 0) + ) { + throw new Error(`${message} "description" must be a non-empty string when provided.`); + } + if ( + record.namespace !== undefined && + record.namespace !== null && + typeof record.namespace !== "string" && + typeof record.namespace !== "function" + ) { + throw new Error(`${message} "namespace" must be a string, null, or resolver.`); + } + if ( + record.scope !== null && + typeof record.scope !== "string" && + typeof record.scope !== "function" + ) { + throw new Error(`${message} "scope" must be a string, null, or resolver.`); + } + const provider = expectObjectRecord(record.provider, `${message} "provider" must be an object.`); + expectOnlyKnownKeys(provider, ["recall", "capture", "tools"], `${message} "provider"`); + const recall = expectObjectRecord( + provider.recall, + `${message} "provider.recall" must be an object.`, + ); + expectOnlyKnownKeys( + recall, + ["turn.started", "compaction.completed"], + `${message} "provider.recall"`, + ); + if (typeof recall["turn.started"] !== "function") { + throw new Error(`${message} provider.recall["turn.started"] must be a function.`); + } + if ( + recall["compaction.completed"] !== undefined && + typeof recall["compaction.completed"] !== "function" + ) { + throw new Error( + `${message} provider.recall["compaction.completed"] must be a function when provided.`, + ); + } + if (provider.capture !== undefined) { + const capture = expectObjectRecord( + provider.capture, + `${message} "provider.capture" must be an object when provided.`, + ); + expectOnlyKnownKeys( + capture, + ["compaction.requested", "turn.completed"], + `${message} "provider.capture"`, + ); + for (const event of ["compaction.requested", "turn.completed"] as const) { + if (capture[event] !== undefined && typeof capture[event] !== "function") { + throw new Error( + `${message} provider.capture[${JSON.stringify(event)}] must be a function when provided.`, + ); + } + } + } + if (provider.tools !== undefined && typeof provider.tools !== "function") { + throw new Error(`${message} "provider.tools" must be a function when provided.`); + } + if (record.tools !== undefined && record.tools !== false) { + throw new Error(`${message} "tools" may only be false when provided.`); + } + if ( + record.visibility !== undefined && + !["scope", "session"].includes(String(record.visibility)) + ) { + throw new Error(`${message} "visibility" must be "scope" or "session".`); + } + return value as MemoryDefinition; +} diff --git a/packages/eve/src/internal/authored-definition/schema-backed.ts b/packages/eve/src/internal/authored-definition/schema-backed.ts index 10e5efddaf..24e71d4c95 100644 --- a/packages/eve/src/internal/authored-definition/schema-backed.ts +++ b/packages/eve/src/internal/authored-definition/schema-backed.ts @@ -15,6 +15,7 @@ import { type ToolSchemaSource, } from "#tools/schema.js"; import { normalizeApproval } from "#internal/authored-definition/approval.js"; +import { shouldRebindDynamicCallbacks } from "#internal/dynamic-tool-rebind.js"; import { assertResolverOnlyDynamicSentinel, isDynamicSentinel, @@ -52,6 +53,7 @@ type NormalizedToolEntry = | { readonly kind: "dynamic-tool"; readonly eventNames: readonly DynamicToolEventName[]; + readonly rebindMissingCallbacks: boolean; }; /** @@ -67,6 +69,7 @@ export function normalizeToolDefinition(value: unknown, message: string): Normal return { kind: "dynamic-tool", eventNames: Object.keys(value.events) as DynamicToolEventName[], + rebindMissingCallbacks: shouldRebindDynamicCallbacks(value), }; } if (isDisabledToolSentinel(value)) { diff --git a/packages/eve/src/internal/authored-module-map-loader.ts b/packages/eve/src/internal/authored-module-map-loader.ts index a0298b345f..0e4d721445 100644 --- a/packages/eve/src/internal/authored-module-map-loader.ts +++ b/packages/eve/src/internal/authored-module-map-loader.ts @@ -7,6 +7,7 @@ import type { CompiledAgentResources, } from "#compiler/manifest.js"; import { ROOT_COMPILED_AGENT_NODE_ID } from "#compiler/manifest.js"; +import { memoizeModuleNamespaceFactories } from "#compiler/source-graph.js"; import { collectModuleBindingsForManifest, compiledModuleMapSchema, @@ -87,10 +88,12 @@ async function hydrateCompiledNodeScope( ), ), ) - : await loadAuthoredModuleNamespace(binding.backing.sourcePath, { - externalDependencies: binding.backing.externalDependencies, - extensionScopeNamespace: resolveCompiledModuleExtensionScopeNamespace(binding), - }); + : memoizeModuleNamespaceFactories( + await loadAuthoredModuleNamespace(binding.backing.sourcePath, { + externalDependencies: binding.backing.externalDependencies, + extensionScopeNamespace: resolveCompiledModuleExtensionScopeNamespace(binding), + }), + ); } finally { if (mountConfigScope !== undefined) container[EXT_CONFIG_SCOPE] = undefined; } diff --git a/packages/eve/src/internal/dynamic-tool-rebind.ts b/packages/eve/src/internal/dynamic-tool-rebind.ts new file mode 100644 index 0000000000..11234f60fd --- /dev/null +++ b/packages/eve/src/internal/dynamic-tool-rebind.ts @@ -0,0 +1,14 @@ +import type { DynamicSentinel } from "#dynamic/definition.js"; + +const REBIND_MISSING_CALLBACKS = Symbol.for("eve:dynamic-rebind-missing-callbacks"); + +export function markDynamicCallbackRebind( + sentinel: DynamicSentinel, +): DynamicSentinel { + Object.defineProperty(sentinel, REBIND_MISSING_CALLBACKS, { value: true }); + return sentinel; +} + +export function shouldRebindDynamicCallbacks(value: DynamicSentinel): boolean { + return Reflect.get(value, REBIND_MISSING_CALLBACKS) === true; +} diff --git a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts index 4ff8e41894..cc1af264de 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from "vitest"; import { compileFromMemory } from "#compiler/compile-from-memory.js"; +import { AgentInfoResultSchema } from "#client/agent-info-schema.js"; import { buildAgentInfoResponse } from "#internal/nitro/routes/agent-info/build-agent-info-response.js"; import { defineInstrumentation } from "#public/instrumentation/index.js"; import { experimental_workflow } from "#tools/workflow.js"; import { webSearch } from "#tools/provided/web-search.js"; +import { defineMemory } from "#public/memory/index.js"; describe("buildAgentInfoResponse", () => { - it("projects v3 exclusively from the effective compiled graph", async () => { + it("projects v4 exclusively from the effective compiled graph", async () => { const { manifest } = await compileFromMemory({ model: "openai/gpt-5.4", name: "info-agent", @@ -34,7 +36,7 @@ describe("buildAgentInfoResponse", () => { }, capabilities: { devRoutes: true }, kind: "eve-agent-info", - version: 3, + version: 4, }); expect(response.tools.static).toContainEqual( expect.objectContaining({ @@ -62,6 +64,54 @@ describe("buildAgentInfoResponse", () => { ); }); + it("reports selected memory and provider-tool wrapper provenance", async () => { + const { manifest } = await compileFromMemory({ + model: "openai/gpt-5.4", + modules: [ + { + loadNamespace: async () => ({ + default: defineMemory({ + description: "Caller profile.", + provider: { + recall: { "turn.started": async () => null }, + tools: async () => ({}), + }, + scope: "user_1", + }), + }), + logicalPath: "memory/profile.ts", + }, + ], + }); + const response = buildAgentInfoResponse( + { manifest, schedules: [] }, + { + gatewayCredentials: { apiKey: false, oidc: false }, + mode: "production", + }, + ); + + expect(response.memories).toContainEqual( + expect.objectContaining({ + description: "Caller profile.", + slot: "profile", + visibility: "scope", + }), + ); + expect(response.tools.dynamic).toContainEqual( + expect.objectContaining({ + binding: expect.objectContaining({ + backing: expect.objectContaining({ + dependencies: { memory: response.memories[0]?.sourceId }, + parameters: expect.objectContaining({ slot: "profile" }), + }), + }), + slug: "profile", + }), + ); + expect(() => AgentInfoResultSchema.parse(response)).not.toThrow(); + }); + it("derives kernel effects only from active framework-owned canonical slots", async () => { const { manifest } = await compileFromMemory({ model: "openai/gpt-5.4" }); const response = buildAgentInfoResponse( diff --git a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts index d604f9fe8f..4ae78fbb9c 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts @@ -28,7 +28,7 @@ function toChatGptEndpoint(state: ChatGptAuthState | undefined) { return endpoint; } -/** Projects v3 exclusively from the effective compiled graph. */ +/** Projects v4 exclusively from the effective compiled graph. */ export function buildAgentInfoResponse( data: AgentInfoManifestData, input: { @@ -131,6 +131,13 @@ export function buildAgentInfoResponse( : toModuleSource(manifest, manifest.instrumentation), kernelEffects: projectPreparedKernelEffects(manifest), kind: "eve-agent-info", + memories: manifest.memories.map((memory) => ({ + ...toModuleSource(manifest, memory), + description: memory.description, + slot: memory.slot, + tools: memory.tools, + visibility: memory.visibility, + })), mode: input.mode, remoteAgents: { entries: remoteAgents, @@ -201,7 +208,7 @@ export function buildAgentInfoResponse( requiresApproval: tool.requiresApproval, })), }, - version: 3, + version: 4, workflow: manifest.workflowTool === undefined ? { enabled: false, toolName: WORKFLOW_TOOL_NAME } @@ -314,6 +321,7 @@ function summarizeNode(node: CompiledAgentNodeManifest | CompiledAgentResources) connections: node.connections.length, hooks: node.hooks.length, instructions: node.instructions.length, + memories: node.memories.length, schedules: node.schedules.length, skills: node.skills.length, tools: node.tools.length, diff --git a/packages/eve/src/internal/nitro/routes/info.test.ts b/packages/eve/src/internal/nitro/routes/info.test.ts index eb5eca4bbd..5c7709f1b8 100644 --- a/packages/eve/src/internal/nitro/routes/info.test.ts +++ b/packages/eve/src/internal/nitro/routes/info.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - buildAgentInfoResponse: vi.fn(() => ({ kind: "eve-agent-info", version: 3 })), + buildAgentInfoResponse: vi.fn(() => ({ kind: "eve-agent-info", version: 4 })), getVercelOidcToken: vi.fn(), refreshChatGptState: vi.fn(async () => ({ kind: "ready" as const })), loadAgentInfoManifestData: vi.fn(async (): Promise => ({ diff --git a/packages/eve/src/internal/programmatic-source-loader.ts b/packages/eve/src/internal/programmatic-source-loader.ts index 482f2b7261..0466045c84 100644 --- a/packages/eve/src/internal/programmatic-source-loader.ts +++ b/packages/eve/src/internal/programmatic-source-loader.ts @@ -1 +1,2 @@ export { loadFrameworkProgrammaticModule } from "#framework/sources/registry.js"; +export { memoizeModuleNamespaceFactories } from "#compiler/source-graph.js"; diff --git a/packages/eve/src/internal/testing/agent-info-fixture.ts b/packages/eve/src/internal/testing/agent-info-fixture.ts index 954370720e..2b14928f33 100644 --- a/packages/eve/src/internal/testing/agent-info-fixture.ts +++ b/packages/eve/src/internal/testing/agent-info-fixture.ts @@ -47,6 +47,7 @@ export function createTestAgentInfoResult( instructions: { dynamic: [], static: [] }, kernelEffects: [], kind: "eve-agent-info", + memories: [], mode: "development", remoteAgents: { entries: [], total: 0 }, sandbox: { @@ -70,7 +71,7 @@ export function createTestAgentInfoResult( skills: { dynamic: [], static: [] }, subagents: { local: [], total: 0 }, tools: { dynamic: [], static: [] }, - version: 3, + version: 4, workflow: { enabled: false, toolName: "Workflow" }, workspace: { resourceRoot: null, rootEntries: [] }, }; diff --git a/packages/eve/src/public/memory/index.test.ts b/packages/eve/src/public/memory/index.test.ts new file mode 100644 index 0000000000..6ddeaae141 --- /dev/null +++ b/packages/eve/src/public/memory/index.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { defaultNamespace, type MemoryScopeContext } from "#public/memory/index.js"; +import { byPrincipal } from "#public/memory/scope.js"; + +const baseContext: MemoryScopeContext = { + abortSignal: new AbortController().signal, + channel: { kind: "eve" }, + session: { auth: { current: null, initiator: null }, id: "session_1" }, +}; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("memory namespaces and scopes", () => { + it("derives stable local namespaces without persisting the raw application root", () => { + vi.stubEnv("VERCEL_PROJECT_ID", ""); + vi.stubEnv("VERCEL_OIDC_TOKEN", ""); + const input = { appRoot: "/Users/example/private/app", node: "__root__", slot: "profile" }; + + const first = defaultNamespace(input); + const second = defaultNamespace(input); + + expect(first).toBe(second); + expect(first).not.toContain(input.appRoot); + expect(JSON.parse(first)).toMatchObject([ + "eve-memory-default-namespace-v1", + "local", + expect.any(String), + "__root__", + "profile", + ]); + }); + + it("separates production and preview namespaces by stable deployment coordinates", () => { + vi.stubEnv("VERCEL_PROJECT_ID", "prj_123"); + vi.stubEnv("VERCEL_ENV", "preview"); + vi.stubEnv("VERCEL_GIT_COMMIT_REF", "feature/memory"); + const preview = defaultNamespace({ appRoot: "/app", node: "__root__", slot: "profile" }); + + vi.stubEnv("VERCEL_ENV", "production"); + const production = defaultNamespace({ + appRoot: "/app", + node: "__root__", + slot: "profile", + }); + + expect(JSON.parse(preview)).toEqual([ + "eve-memory-default-namespace-v1", + "vercel", + "prj_123", + "preview", + "feature/memory", + "__root__", + "profile", + ]); + expect(preview).not.toBe(production); + }); + + it("disables anonymous and runtime principals and normalizes local development", () => { + const context = (principalType: string, principalId = "principal") => ({ + ...baseContext, + session: { + ...baseContext.session, + auth: { + current: { + attributes: {}, + authenticator: principalType, + principalId, + principalType, + }, + initiator: null, + }, + }, + }); + + expect(byPrincipal(baseContext)).toBeNull(); + expect(byPrincipal(context("anonymous"))).toBeNull(); + expect(byPrincipal(context("runtime"))).toBeNull(); + expect(byPrincipal(context("local-dev", "machine-specific"))).toBe("local-dev"); + }); + + it("includes the authenticated principal coordinates without delimiter flattening", () => { + const context: MemoryScopeContext = { + ...baseContext, + session: { + ...baseContext.session, + auth: { + current: { + attributes: {}, + authenticator: "oidc:primary", + issuer: "https://issuer.example", + principalId: "user:123", + principalType: "user", + }, + initiator: null, + }, + }, + }; + + expect(JSON.parse(byPrincipal(context)!)).toEqual([ + "user", + "oidc:primary", + "https://issuer.example", + "user:123", + ]); + }); +}); diff --git a/packages/eve/src/public/memory/index.ts b/packages/eve/src/public/memory/index.ts new file mode 100644 index 0000000000..72ed1e15b0 --- /dev/null +++ b/packages/eve/src/public/memory/index.ts @@ -0,0 +1,199 @@ +import { createHash } from "node:crypto"; + +import type { ModelMessage } from "ai"; + +import type { SessionAuth } from "#context/keys.js"; +import type { Approval } from "#public/definitions/approval.js"; +import type { SessionContext } from "#public/definitions/callback-context.js"; +import type { ExactDefinition } from "#public/definitions/exact.js"; +import type { DynamicResolveContext } from "#dynamic/definition.js"; +import { MEMORY_DEFINITION_BRAND } from "#shared/memory-definition.js"; +import { resolveVercelProjectIdFromEnvironment } from "#shared/vercel-project.js"; +import type { ToolContext, ToolModelOutput } from "#tools/definition.js"; + +export interface MemoryNamespaceContext { + readonly appRoot: string; + readonly node: string; + readonly slot: string; +} + +export type MemoryNamespaceDefinition = + | string + | null + | ((context: MemoryNamespaceContext) => string | null | Promise); + +export type MemoryScopeResolverResult = string | readonly string[] | null; + +export interface MemoryScopeContext { + readonly abortSignal: AbortSignal; + readonly session: { + readonly id: string; + readonly auth: SessionAuth; + }; + readonly channel: { + readonly kind?: string; + readonly continuationToken?: string; + readonly metadata?: Readonly>; + }; +} + +export type MemoryScopeDefinition = + | string + | null + | (( + context: MemoryScopeContext, + ) => MemoryScopeResolverResult | Promise); + +export interface MemoryScope { + readonly key: string; + readonly namespace: string; + readonly value: string | readonly string[]; +} + +export interface MemoryRecallMessage { + readonly content: string; + readonly id?: string; +} + +export type MemoryRecallResult = + | { readonly messages: readonly MemoryRecallMessage[] } + | null + | undefined; + +export interface MemoryTurnContext { + readonly id: string; + readonly input: readonly ModelMessage[]; + readonly sequence: number; +} + +export interface MemoryOperationContext extends SessionContext { + readonly abortSignal: AbortSignal; + readonly messages: readonly ModelMessage[]; + readonly operationId: string; + readonly memory: { + readonly scope: MemoryScope; + readonly slot: string; + }; +} + +export interface MemoryTurnStartedContext extends MemoryOperationContext { + readonly turn: MemoryTurnContext; +} + +export interface MemoryCompactionCompletedContext extends MemoryOperationContext { + readonly turn: MemoryTurnContext | null; + readonly compaction: { readonly modelId: string }; +} + +export interface MemoryCompactionRequestedContext extends MemoryOperationContext { + readonly turn: MemoryTurnContext | null; + readonly compaction: { + readonly modelId: string; + readonly usageInputTokens: number | null; + }; +} + +export interface MemoryTurnCompletedContext extends MemoryOperationContext { + readonly turn: MemoryTurnContext; +} + +export type MemoryRecallHandler = ( + context: TContext, +) => MemoryRecallResult | Promise; + +export type MemoryCaptureHandler = ( + context: TContext, +) => void | Promise; + +export interface MemoryToolsContext extends DynamicResolveContext { + readonly memory: { + readonly scope: MemoryScope; + readonly slot: string; + }; + readonly turn: MemoryTurnContext; +} + +export interface MemoryToolDefinition { + readonly approval?: Approval; + readonly description: string; + readonly execution?: never; + execute(input: never, context: ToolContext): unknown | Promise | AsyncIterable; + readonly inputSchema: unknown; + readonly outputSchema?: unknown; + readonly toModelOutput?: (output: never) => ToolModelOutput | Promise; +} + +export type MemoryToolSet = Readonly>; + +export interface MemoryProvider { + readonly recall: { + readonly "turn.started": MemoryRecallHandler; + readonly "compaction.completed"?: MemoryRecallHandler; + }; + readonly capture?: { + readonly "compaction.requested"?: MemoryCaptureHandler; + readonly "turn.completed"?: MemoryCaptureHandler; + }; + readonly tools?: (context: MemoryToolsContext) => Promise; +} + +export type MemoryVisibility = "scope" | "session"; + +export interface MemoryDefinition { + readonly description?: string; + readonly namespace?: MemoryNamespaceDefinition; + readonly provider: MemoryProvider; + readonly scope: MemoryScopeDefinition; + readonly tools?: false; + readonly visibility?: MemoryVisibility; +} + +export type DefinedMemory = T & { + readonly [MEMORY_DEFINITION_BRAND]: true; +}; + +export function defineMemoryProvider( + provider: ExactDefinition, +): T { + return provider; +} + +export function defineMemory( + definition: ExactDefinition, +): DefinedMemory; +export function defineMemory(definition: MemoryDefinition): DefinedMemory { + Object.assign(definition, { [MEMORY_DEFINITION_BRAND]: true }); + return definition as DefinedMemory; +} + +export function defaultNamespace(context: MemoryNamespaceContext): string { + const projectId = resolveVercelProjectIdFromEnvironment(); + if (projectId === undefined) { + return JSON.stringify([ + "eve-memory-default-namespace-v1", + "local", + createHash("sha256").update(context.appRoot).digest("base64url"), + context.node, + context.slot, + ]); + } + + const environment = + process.env.VERCEL_TARGET_ENV?.trim() || process.env.VERCEL_ENV?.trim() || "production"; + const previewIdentity = + environment === "preview" + ? process.env.VERCEL_GIT_COMMIT_REF?.trim() || + process.env.VERCEL_DEPLOYMENT_ID?.trim() || + process.env.VERCEL_URL?.trim() || + "unknown-preview" + : null; + return JSON.stringify([ + "eve-memory-default-namespace-v1", + "vercel", + projectId, + environment, + previewIdentity, + context.node, + context.slot, + ]); +} diff --git a/packages/eve/src/public/memory/scope.ts b/packages/eve/src/public/memory/scope.ts new file mode 100644 index 0000000000..bb4834ef58 --- /dev/null +++ b/packages/eve/src/public/memory/scope.ts @@ -0,0 +1,15 @@ +import type { MemoryScopeContext } from "#public/memory/index.js"; + +const DISABLED_PRINCIPAL_TYPES = new Set(["anonymous", "runtime"]); + +export function byPrincipal(context: MemoryScopeContext): string | null { + const principal = context.session.auth.current; + if (principal === null || DISABLED_PRINCIPAL_TYPES.has(principal.principalType)) return null; + if (principal.principalType === "local-dev") return "local-dev"; + return JSON.stringify([ + principal.principalType, + principal.authenticator, + principal.issuer ?? null, + principal.principalId, + ]); +} diff --git a/packages/eve/src/runtime/resolve-agent.ts b/packages/eve/src/runtime/resolve-agent.ts index defc47dc4b..33fa7e4187 100644 --- a/packages/eve/src/runtime/resolve-agent.ts +++ b/packages/eve/src/runtime/resolve-agent.ts @@ -20,6 +20,7 @@ import { resolveDynamicInstructionsDefinition } from "#runtime/resolve-dynamic-i import { resolveDynamicSkillDefinition } from "#runtime/resolve-dynamic-skill.js"; import { resolveDynamicToolDefinition } from "#runtime/resolve-dynamic-tool.js"; import { resolveToolDefinition } from "#runtime/resolve-tool.js"; +import { resolveMemoryDefinition } from "#runtime/resolve-memory.js"; import type { ResolvedAgent, ResolvedChannelDefinition, @@ -93,6 +94,11 @@ export async function resolveAgent(input: ResolveAgentInput): Promise + resolveMemoryDefinition(definition, input.moduleMap, input.nodeId), + ), + ); const authoredSandbox = await resolveSandboxDefinition( input.manifest.sandbox, input.moduleMap, @@ -120,6 +126,7 @@ export async function resolveAgent(input: ResolveAgentInput): Promise; @@ -105,6 +106,7 @@ function createResolvedDynamicToolResolver( exportName: source.exportName, extensionNamespace: source.extensionNamespace, logicalPath: source.logicalPath, + rebindMissingCallbacks: source.rebindMissingCallbacks, slug: source.slug, sourceId: source.sourceId, sourceKind: "module", diff --git a/packages/eve/src/runtime/resolve-memory.ts b/packages/eve/src/runtime/resolve-memory.ts new file mode 100644 index 0000000000..8342588ea9 --- /dev/null +++ b/packages/eve/src/runtime/resolve-memory.ts @@ -0,0 +1,42 @@ +import type { CompiledMemoryDefinition } from "#compiler/manifest.js"; +import type { CompiledModuleMap } from "#compiler/module-map.js"; +import { normalizeMemoryDefinition } from "#internal/authored-definition/memory.js"; +import { loadResolvedModuleExport, ResolveAgentError } from "#runtime/resolve-helpers.js"; +import type { ResolvedMemoryDefinition } from "#runtime/types.js"; +import { toErrorMessage } from "#shared/errors.js"; + +export async function resolveMemoryDefinition( + compiled: CompiledMemoryDefinition, + moduleMap: CompiledModuleMap, + nodeId: string | undefined, +): Promise { + try { + const value = await loadResolvedModuleExport({ + definition: compiled, + kindLabel: "memory", + moduleMap, + nodeId, + }); + const definition = normalizeMemoryDefinition( + value, + `Expected the memory export "${compiled.exportName ?? "default"}" from "${compiled.logicalPath}" to be created with defineMemory().`, + ); + return { + ...definition, + description: compiled.description, + exportName: compiled.exportName, + logicalPath: compiled.logicalPath, + slot: compiled.slot, + sourceId: compiled.sourceId, + sourceKind: "module", + tools: compiled.tools, + visibility: compiled.visibility, + }; + } catch (error) { + if (error instanceof ResolveAgentError) throw error; + throw new ResolveAgentError( + `Failed to resolve memory from "${compiled.logicalPath}": ${toErrorMessage(error)}`, + { logicalPath: compiled.logicalPath, sourceId: compiled.sourceId }, + ); + } +} diff --git a/packages/eve/src/runtime/types.ts b/packages/eve/src/runtime/types.ts index 39bcd52b68..1fb87d99a5 100644 --- a/packages/eve/src/runtime/types.ts +++ b/packages/eve/src/runtime/types.ts @@ -39,6 +39,7 @@ import type { SandboxBackend } from "#shared/sandbox-backend.js"; import type { SandboxBootstrapContext, SandboxSessionContext } from "#shared/sandbox-definition.js"; import type { ToolSchema } from "#tools/schema.js"; import type { AgentSourceOwner } from "#compiler/source-graph.js"; +import type { MemoryDefinition } from "#public/memory/index.js"; /** * Runtime-owned source ref describing one additive config module import. @@ -363,6 +364,7 @@ export interface ResolvedDynamicToolResolver extends Readonly { readonly events: Readonly< Record unknown | Promise> >; + readonly rebindMissingCallbacks?: boolean; /** * Mount namespace when this resolver comes from an extension. Names of tools * the resolver produces are prefixed with `${extensionNamespace}__`. @@ -370,6 +372,14 @@ export interface ResolvedDynamicToolResolver extends Readonly { readonly extensionNamespace?: string; } +export type ResolvedMemoryDefinition = Readonly< + MemoryDefinition & + ModuleSourceRef & { + readonly slot: string; + readonly visibility: "scope" | "session"; + } +>; + /** * Runtime resolver for dynamic skills declared via `defineDynamic({ events })` * in `agent/skills/`. Carries the live event handler functions loaded from the @@ -437,6 +447,7 @@ export interface ResolvedAgent { */ readonly workspaceResourceRoot: CompiledWorkspaceResourceRoot; readonly hooks: readonly ResolvedHookDefinition[]; + readonly memories: readonly ResolvedMemoryDefinition[]; readonly skills: readonly ResolvedSkillDefinition[]; readonly tools: readonly ResolvedToolDefinition[]; readonly workspaceSpec: WorkspaceRuntimeSpec; diff --git a/packages/eve/src/shared/memory-definition.ts b/packages/eve/src/shared/memory-definition.ts new file mode 100644 index 0000000000..9d1733c1fb --- /dev/null +++ b/packages/eve/src/shared/memory-definition.ts @@ -0,0 +1,9 @@ +export const MEMORY_DEFINITION_BRAND = Symbol.for("eve:memory-definition"); + +export function isMemoryDefinition(value: unknown): boolean { + return ( + typeof value === "object" && + value !== null && + Reflect.get(value, MEMORY_DEFINITION_BRAND) === true + ); +} diff --git a/packages/eve/src/shared/memory-state.test.ts b/packages/eve/src/shared/memory-state.test.ts new file mode 100644 index 0000000000..86736bb14a --- /dev/null +++ b/packages/eve/src/shared/memory-state.test.ts @@ -0,0 +1,211 @@ +import type { ModelMessage } from "ai"; +import { describe, expect, it } from "vitest"; + +import { + applyMemoryRecallBatches, + canonicalizeMemoryRecords, + clearMemorySessionState, + createMemoryLock, + projectMemoryHistory, + readMemoryLocks, + shouldCanonicalizeMemory, + validateMemoryRecallResult, +} from "#shared/memory-state.js"; + +const turn = Object.freeze({ id: "turn_1", input: [], sequence: 1 }); + +function lock( + scope: string | readonly string[] = "user_1", + visibility: "scope" | "session" = "scope", +) { + return createMemoryLock({ + namespace: "app", + scope, + slot: "profile", + turn, + visibility, + }); +} + +function recall(input: { + readonly history?: readonly ModelMessage[]; + readonly operationId: string; + readonly records: readonly { readonly content: string; readonly id?: string }[]; + readonly scope?: string | readonly string[]; + readonly state?: Readonly>; + readonly visibility?: "scope" | "session"; +}) { + const memoryLock = lock(input.scope, input.visibility); + return applyMemoryRecallBatches({ + batches: [ + { + lock: memoryLock, + messages: validateMemoryRecallResult({ messages: input.records }, "profile"), + operationId: input.operationId, + }, + ], + history: input.history ?? [], + state: input.state, + }); +} + +describe("memory record state", () => { + it("uses typed, collision-resistant namespace and scope encodings", () => { + const scalar = lock("a\u0000b"); + const tuple = lock(["a", "b"]); + const otherNamespace = createMemoryLock({ + namespace: "app\u0000user_1", + scope: "user_1", + slot: "profile", + turn, + visibility: "scope", + }); + + expect(scalar.namespaceKey).toMatch(/^memns1_[A-Za-z0-9_-]{43}$/u); + expect(scalar.scopeKey).toMatch(/^memscope1_[A-Za-z0-9_-]{43}$/u); + expect(scalar.scope.key).toMatch(/^memscope1_[A-Za-z0-9_-]{43}$/u); + expect(scalar.scopeKey).not.toBe(tuple.scopeKey); + expect(scalar.scope.key).not.toBe(otherNamespace.scope.key); + }); + + it("rejects invalid or oversized namespace, scope, and recall identifiers", () => { + expect(() => lock([])).toThrow("must not be empty"); + expect(() => lock(" ")).toThrow("non-empty string"); + expect(() => lock("x".repeat(1_025))).toThrow("1024 UTF-8 bytes"); + expect(() => lock(Array.from({ length: 17 }, (_, index) => String(index)))).toThrow( + "16 components", + ); + expect(() => + createMemoryLock({ + namespace: "x".repeat(1_025), + scope: "user_1", + slot: "profile", + turn, + visibility: "scope", + }), + ).toThrow("Memory namespace exceeds 1024 UTF-8 bytes"); + expect(() => + validateMemoryRecallResult( + { messages: [{ content: "value", id: "x".repeat(1_025) }] }, + "profile", + ), + ).toThrow("id exceeds 1024 UTF-8 bytes"); + }); + + it("validates the exact recall shape before records enter history", () => { + expect(() => + validateMemoryRecallResult( + { + messages: [ + { content: "value", id: "same" }, + { content: "other", id: "same" }, + ], + }, + "profile", + ), + ).toThrow('duplicate id "same"'); + expect(() => validateMemoryRecallResult({ messages: [{ content: " " }] }, "profile")).toThrow( + "content must be non-blank", + ); + expect(() => + validateMemoryRecallResult( + { messages: [{ content: "value", extra: true } as never] }, + "profile", + ), + ).toThrow("unknown key(s): extra"); + }); + + it("supersedes keyed records, preserves unkeyed appends, and makes identical values a no-op", () => { + const first = recall({ + operationId: "op_1", + records: [{ content: "old profile", id: "profile" }, { content: "first note" }], + }); + const second = recall({ + history: first.history, + operationId: "op_2", + records: [{ content: "new profile", id: "profile" }, { content: "second note" }], + state: first.state, + }); + const identical = recall({ + history: second.history, + operationId: "op_3", + records: [{ content: "new profile", id: "profile" }], + state: second.state, + }); + + expect(identical.history).toHaveLength(second.history.length); + expect( + projectMemoryHistory({ locks: readMemoryLocks(second.state), messages: second.history }), + ).toEqual([ + { content: "first note", role: "user" }, + { content: "new profile", role: "user" }, + { content: "second note", role: "user" }, + ]); + }); + + it("fails closed when an operation is replayed with a different result", () => { + const first = recall({ operationId: "stable", records: [{ content: "one", id: "key" }] }); + expect(() => + recall({ + history: first.history, + operationId: "stable", + records: [{ content: "two", id: "key" }], + state: first.state, + }), + ).toThrow("replayed with a different result"); + }); + + it("enforces scope visibility while session visibility survives a scope change", () => { + const scoped = recall({ operationId: "scope", records: [{ content: "private" }] }); + expect( + projectMemoryHistory({ locks: { profile: lock("user_2") }, messages: scoped.history }), + ).toEqual([]); + + const sessionVisible = recall({ + operationId: "session", + records: [{ content: "sticky" }], + visibility: "session", + }); + expect( + projectMemoryHistory({ + locks: { profile: lock("user_2", "session") }, + messages: sessionVisible.history, + }), + ).toEqual([{ content: "sticky", role: "user" }]); + }); + + it("canonicalizes private records independently", () => { + let result = recall({ + operationId: "canonical_1", + records: [{ content: "old", id: "key" }], + }); + result = recall({ + history: result.history, + operationId: "canonical_2", + records: [{ content: "new", id: "key" }, { content: "note" }], + state: result.state, + }); + const ordinary = { content: "ordinary", role: "assistant" as const }; + const canonical = canonicalizeMemoryRecords([...result.history, ordinary]); + + expect(canonical.memory).toHaveLength(2); + expect(canonical.ordinary).toEqual([ordinary]); + }); + + it("uses independent raw-log triggers and clears all framework memory state", () => { + let result = recall({ operationId: "raw_0", records: [{ content: "note" }] }); + for (let index = 1; index <= 512; index++) { + result = recall({ + history: result.history, + operationId: `raw_${index}`, + records: [{ content: `note ${index}` }], + state: result.state, + }); + } + + expect(shouldCanonicalizeMemory(result.history)).toBe(true); + expect(clearMemorySessionState({ ...result.state, retained: true })).toEqual({ + retained: true, + }); + }); +}); diff --git a/packages/eve/src/shared/memory-state.ts b/packages/eve/src/shared/memory-state.ts new file mode 100644 index 0000000000..133309dc09 --- /dev/null +++ b/packages/eve/src/shared/memory-state.ts @@ -0,0 +1,460 @@ +import { createHash } from "node:crypto"; + +import type { ModelMessage } from "ai"; + +import type { LockedMemorySlot } from "#context/keys.js"; +import type { SessionStateMap } from "#harness/types.js"; +import { identityHistoryViewProjector } from "#shared/history-view.js"; +import type { + MemoryRecallResult, + MemoryScope, + MemoryScopeResolverResult, +} from "#public/memory/index.js"; + +export const MEMORY_NAMESPACE_MAX_BYTES = 1_024; +export const MEMORY_SCOPE_COMPONENT_MAX_BYTES = 1_024; +export const MEMORY_SCOPE_TUPLE_MAX_COMPONENTS = 16; +export const MEMORY_CANONICAL_KEY_INPUT_MAX_BYTES = 4_096; +export const MEMORY_ITEM_ID_MAX_BYTES = 1_024; +export const MEMORY_RAW_RECORD_MAX_COUNT = 512; +export const MEMORY_RAW_RECORD_MAX_BYTES = 262_144; + +const MEMORY_MESSAGE_METADATA_KEY = "eve.memory"; +const MEMORY_SESSION_STATE_KEY = "eve.memory"; +const MEMORY_RECORD_VERSION = 1; +const MAX_OPERATION_DIGESTS = 1_024; + +export interface InternalMemoryLock extends LockedMemorySlot { + readonly namespaceKey: string; + readonly scopeKey: string; +} + +interface MemoryRecordAttribution { + readonly batchIndex: number; + readonly itemKey?: string; + readonly namespaceKey: string; + readonly operationId: string; + readonly scopeKey: string; + readonly slot: string; + readonly version: typeof MEMORY_RECORD_VERSION; +} + +interface MemorySessionState { + readonly locks: Readonly>; + readonly operationDigests: Readonly>; +} + +export interface NormalizedMemoryRecallMessage { + readonly content: string; + readonly itemKey?: string; +} + +export interface MemoryRecallBatch { + readonly lock: InternalMemoryLock; + readonly messages: readonly NormalizedMemoryRecallMessage[]; + readonly operationId: string; +} + +export function createMemoryLock(input: { + readonly namespace: string; + readonly scope: Exclude; + readonly slot: string; + readonly turn: LockedMemorySlot["turn"]; + readonly visibility: LockedMemorySlot["visibility"]; +}): InternalMemoryLock { + validateMemoryNamespace(input.namespace); + validateMemoryScopeValue(input.scope); + const namespaceEncoding = encodeScalar("namespace", input.namespace); + const scopeEncoding = encodeScope(input.scope); + const canonicalInputBytes = namespaceEncoding.byteLength + scopeEncoding.byteLength; + if (canonicalInputBytes > MEMORY_CANONICAL_KEY_INPUT_MAX_BYTES) { + throw new Error( + `Memory slot "${input.slot}" namespace and scope encoding exceeds ${MEMORY_CANONICAL_KEY_INPUT_MAX_BYTES} UTF-8 bytes.`, + ); + } + const namespaceKey = digest("memns1_", namespaceEncoding); + const scopeKey = digest("memscope1_", scopeEncoding); + const composite = Buffer.concat([ + Buffer.from("eve-memory-composite-v1\0"), + lengthPrefix(Buffer.from(namespaceKey)), + lengthPrefix(Buffer.from(scopeKey)), + ]); + const scope: MemoryScope = Object.freeze({ + key: digest("memscope1_", composite), + namespace: input.namespace, + value: Array.isArray(input.scope) ? Object.freeze([...input.scope]) : input.scope, + }); + return Object.freeze({ + namespaceKey, + scope, + scopeKey, + slot: input.slot, + turn: input.turn, + visibility: input.visibility, + }); +} + +export function validateMemoryRecallResult( + result: MemoryRecallResult, + slot: string, +): readonly NormalizedMemoryRecallMessage[] { + if (result === null || result === undefined) return []; + if (typeof result !== "object" || Array.isArray(result)) { + throw new Error(`Memory slot "${slot}" recall() must return { messages }, null, or undefined.`); + } + const unknownResultKeys = Object.keys(result).filter((key) => key !== "messages"); + if (unknownResultKeys.length > 0) { + throw new Error( + `Memory slot "${slot}" recall() returned unknown key(s): ${unknownResultKeys.join(", ")}.`, + ); + } + if (!Array.isArray(result.messages)) { + throw new Error(`Memory slot "${slot}" recall().messages must be an array.`); + } + const ids = new Set(); + return result.messages.map((message, index) => { + if (typeof message !== "object" || message === null || Array.isArray(message)) { + throw new Error(`Memory slot "${slot}" recall message ${index} must be an object.`); + } + const unknownKeys = Object.keys(message).filter((key) => key !== "content" && key !== "id"); + if (unknownKeys.length > 0) { + throw new Error( + `Memory slot "${slot}" recall message ${index} has unknown key(s): ${unknownKeys.join(", ")}.`, + ); + } + if (typeof message.content !== "string" || message.content.trim().length === 0) { + throw new Error(`Memory slot "${slot}" recall message ${index} content must be non-blank.`); + } + if (message.id === undefined) return Object.freeze({ content: message.content }); + if (typeof message.id !== "string" || message.id.length === 0) { + throw new Error(`Memory slot "${slot}" recall message ${index} id must be non-empty.`); + } + if (utf8Bytes(message.id) > MEMORY_ITEM_ID_MAX_BYTES) { + throw new Error( + `Memory slot "${slot}" recall message ${index} id exceeds ${MEMORY_ITEM_ID_MAX_BYTES} UTF-8 bytes.`, + ); + } + if (ids.has(message.id)) { + throw new Error(`Memory slot "${slot}" recall() returned duplicate id "${message.id}".`); + } + ids.add(message.id); + return Object.freeze({ + content: message.content, + itemKey: digest("memitem1_", encodeScalar("item", message.id)), + }); + }); +} + +export function applyMemoryRecallBatches(input: { + readonly batches: readonly MemoryRecallBatch[]; + readonly history: readonly ModelMessage[]; + readonly state: SessionStateMap | undefined; +}): { readonly history: ModelMessage[]; readonly state: SessionStateMap } { + const prior = readMemorySessionState(input.state); + const operationDigests: Record = { ...prior.operationDigests }; + const latest = latestKeyedRecords(input.history); + const appended: ModelMessage[] = []; + + for (const batch of input.batches) { + const digestValue = recallBatchDigest(batch.messages); + const priorDigest = operationDigests[batch.operationId]; + if (priorDigest !== undefined) { + if (priorDigest !== digestValue) { + throw new Error( + `Memory recall operation "${batch.operationId}" replayed with a different result.`, + ); + } + continue; + } + operationDigests[batch.operationId] = digestValue; + for (const [batchIndex, message] of batch.messages.entries()) { + const attribution: MemoryRecordAttribution = { + batchIndex, + itemKey: message.itemKey, + namespaceKey: batch.lock.namespaceKey, + operationId: batch.operationId, + scopeKey: batch.lock.scopeKey, + slot: batch.lock.slot, + version: MEMORY_RECORD_VERSION, + }; + if (message.itemKey !== undefined) { + const identity = memoryItemIdentity(attribution); + const previous = latest.get(identity); + if (previous?.content === message.content) continue; + latest.set(identity, { attribution, content: message.content }); + } + appended.push(attributeMemoryRecord(message.content, attribution)); + } + } + + const trimmedDigests = Object.fromEntries( + Object.entries(operationDigests).slice(-MAX_OPERATION_DIGESTS), + ); + return { + history: [...input.history, ...appended], + state: writeMemorySessionState(input.state, { + locks: Object.fromEntries(input.batches.map((batch) => [batch.lock.slot, batch.lock])), + operationDigests: trimmedDigests, + }), + }; +} + +export function projectMemoryHistory(input: { + readonly locks: Readonly>; + readonly messages: readonly ModelMessage[]; +}): readonly ModelMessage[] { + const liveKeyed = latestKeyedRecordIndexes(input.messages); + return input.messages.flatMap((message, index) => { + const attribution = readMemoryRecordAttribution(message); + if (attribution === null) return [message]; + const lock = input.locks[attribution.slot]; + if ( + lock === undefined || + lock.namespaceKey !== attribution.namespaceKey || + (lock.visibility === "scope" && lock.scopeKey !== attribution.scopeKey) + ) { + return []; + } + if ( + attribution.itemKey !== undefined && + liveKeyed.get(memoryItemIdentity(attribution)) !== index + ) { + return []; + } + return [stripMemoryRecordAttribution(message)]; + }); +} + +export function projectMemoryHistoryFromSessionState(input: { + readonly messages: readonly ModelMessage[]; + readonly state: SessionStateMap | undefined; +}): readonly ModelMessage[] { + const messages = identityHistoryViewProjector(input); + return projectMemoryHistory({ + locks: readMemorySessionState(input.state).locks, + messages, + }); +} + +export function canonicalizeMemoryRecords(messages: readonly ModelMessage[]): { + readonly memory: ModelMessage[]; + readonly ordinary: ModelMessage[]; +} { + const liveKeyed = latestKeyedRecordIndexes(messages); + const memory: ModelMessage[] = []; + const ordinary: ModelMessage[] = []; + for (const [index, message] of messages.entries()) { + const attribution = readMemoryRecordAttribution(message); + if (attribution === null) { + ordinary.push(message); + continue; + } + if ( + attribution.itemKey === undefined || + liveKeyed.get(memoryItemIdentity(attribution)) === index + ) { + memory.push(message); + } + } + return { memory, ordinary }; +} + +export function shouldCanonicalizeMemory(messages: readonly ModelMessage[]): boolean { + let count = 0; + let bytes = 0; + for (const message of messages) { + if (readMemoryRecordAttribution(message) === null) continue; + count += 1; + bytes += serializedBytes(message); + } + return count > MEMORY_RAW_RECORD_MAX_COUNT || bytes > MEMORY_RAW_RECORD_MAX_BYTES; +} + +export function clearMemorySessionState(state: SessionStateMap | undefined): SessionStateMap { + if (state === undefined || !Object.hasOwn(state, MEMORY_SESSION_STATE_KEY)) return state ?? {}; + const { [MEMORY_SESSION_STATE_KEY]: _memory, ...remaining } = state; + return remaining; +} + +export function readMemoryLocks( + state: SessionStateMap | undefined, +): Readonly> { + return readMemorySessionState(state).locks; +} + +function validateMemoryNamespace(namespace: string): void { + if (namespace.trim().length === 0) throw new Error("Memory namespace must be non-empty."); + if (utf8Bytes(namespace) > MEMORY_NAMESPACE_MAX_BYTES) { + throw new Error(`Memory namespace exceeds ${MEMORY_NAMESPACE_MAX_BYTES} UTF-8 bytes.`); + } +} + +function validateMemoryScopeValue(scope: Exclude): void { + const components = Array.isArray(scope) ? scope : [scope]; + if (Array.isArray(scope) && components.length > MEMORY_SCOPE_TUPLE_MAX_COMPONENTS) { + throw new Error(`Memory scope tuple exceeds ${MEMORY_SCOPE_TUPLE_MAX_COMPONENTS} components.`); + } + if (components.length === 0) throw new Error("Memory scope tuple must not be empty."); + for (const [index, component] of components.entries()) { + if (typeof component !== "string" || component.trim().length === 0) { + throw new Error(`Memory scope component ${index} must be a non-empty string.`); + } + if (utf8Bytes(component) > MEMORY_SCOPE_COMPONENT_MAX_BYTES) { + throw new Error( + `Memory scope component ${index} exceeds ${MEMORY_SCOPE_COMPONENT_MAX_BYTES} UTF-8 bytes.`, + ); + } + } +} + +function encodeScope(scope: Exclude): Buffer { + if (typeof scope === "string") return encodeScalar("scope-scalar", scope); + return Buffer.concat([ + Buffer.from("scope-tuple-v1\0"), + uint32(scope.length), + ...scope.map((component) => lengthPrefix(Buffer.from(component, "utf8"))), + ]); +} + +function encodeScalar(type: string, value: string): Buffer { + return Buffer.concat([Buffer.from(`${type}-v1\0`), lengthPrefix(Buffer.from(value, "utf8"))]); +} + +function lengthPrefix(value: Buffer): Buffer { + return Buffer.concat([uint32(value.byteLength), value]); +} + +function uint32(value: number): Buffer { + const result = Buffer.allocUnsafe(4); + result.writeUInt32BE(value); + return result; +} + +function digest(prefix: string, value: Buffer): string { + return `${prefix}${createHash("sha256").update(value).digest("base64url")}`; +} + +function recallBatchDigest(messages: readonly NormalizedMemoryRecallMessage[]): string { + return createHash("sha256").update(JSON.stringify(messages)).digest("base64url"); +} + +function attributeMemoryRecord( + content: string, + attribution: MemoryRecordAttribution, +): ModelMessage { + const message: Extract & { + readonly metadata: Record; + } = { + content, + metadata: { [MEMORY_MESSAGE_METADATA_KEY]: attribution }, + role: "user", + }; + return message; +} + +function readMemoryRecordAttribution(message: unknown): MemoryRecordAttribution | null { + if (typeof message !== "object" || message === null) return null; + const metadata = Reflect.get(message, "metadata"); + if (typeof metadata !== "object" || metadata === null) return null; + const value = Reflect.get(metadata, MEMORY_MESSAGE_METADATA_KEY); + if (typeof value !== "object" || value === null) return null; + const candidate = value as Partial; + if ( + candidate.version !== MEMORY_RECORD_VERSION || + typeof candidate.batchIndex !== "number" || + typeof candidate.namespaceKey !== "string" || + typeof candidate.operationId !== "string" || + typeof candidate.scopeKey !== "string" || + typeof candidate.slot !== "string" || + (candidate.itemKey !== undefined && typeof candidate.itemKey !== "string") + ) { + throw new Error("Durable history contains invalid eve memory attribution."); + } + return candidate as MemoryRecordAttribution; +} + +function stripMemoryRecordAttribution(message: ModelMessage): ModelMessage { + const metadata = Reflect.get(message, "metadata"); + if (typeof metadata !== "object" || metadata === null) return message; + const { [MEMORY_MESSAGE_METADATA_KEY]: _memory, ...remainingMetadata } = metadata as Record< + string, + unknown + >; + const { metadata: _metadata, ...plain } = message as ModelMessage & { + readonly metadata?: Record; + }; + return Object.keys(remainingMetadata).length === 0 + ? (plain as ModelMessage) + : Object.assign(plain, { metadata: remainingMetadata }); +} + +function latestKeyedRecordIndexes(messages: readonly ModelMessage[]): Map { + const indexes = new Map(); + for (const [index, message] of messages.entries()) { + const attribution = readMemoryRecordAttribution(message); + if (attribution?.itemKey !== undefined) indexes.set(memoryItemIdentity(attribution), index); + } + return indexes; +} + +function latestKeyedRecords( + messages: readonly ModelMessage[], +): Map { + const latest = new Map< + string, + { readonly attribution: MemoryRecordAttribution; readonly content: string } + >(); + for (const message of messages) { + const attribution = readMemoryRecordAttribution(message); + if (attribution?.itemKey === undefined) continue; + const rawContent = Reflect.get(message, "content"); + const content = typeof rawContent === "string" ? rawContent : ""; + latest.set(memoryItemIdentity(attribution), { attribution, content }); + } + return latest; +} + +function memoryItemIdentity(attribution: MemoryRecordAttribution): string { + return JSON.stringify([ + attribution.slot, + attribution.namespaceKey, + attribution.scopeKey, + attribution.itemKey, + ]); +} + +function readMemorySessionState(state: SessionStateMap | undefined): MemorySessionState { + const value = state?.[MEMORY_SESSION_STATE_KEY]; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { locks: {}, operationDigests: {} }; + } + const locks = Reflect.get(value, "locks"); + const operationDigests = Reflect.get(value, "operationDigests"); + return { + locks: + typeof locks === "object" && locks !== null && !Array.isArray(locks) + ? (locks as Readonly>) + : {}, + operationDigests: + typeof operationDigests === "object" && + operationDigests !== null && + !Array.isArray(operationDigests) + ? (operationDigests as Readonly>) + : {}, + }; +} + +function writeMemorySessionState( + state: SessionStateMap | undefined, + memory: MemorySessionState, +): SessionStateMap { + return { ...state, [MEMORY_SESSION_STATE_KEY]: memory }; +} + +function serializedBytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), "utf8"); +} + +function utf8Bytes(value: string): number { + return Buffer.byteLength(value, "utf8"); +} diff --git a/packages/eve/test/discover-manifest.test.ts b/packages/eve/test/discover-manifest.test.ts index 4e1cea0e8d..0544e5f5e2 100644 --- a/packages/eve/test/discover-manifest.test.ts +++ b/packages/eve/test/discover-manifest.test.ts @@ -37,6 +37,7 @@ describe("agent source manifest", () => { hooks: [], instructions: [], lib: [], + memories: [], kind: AGENT_SOURCE_MANIFEST_KIND, sandbox: null, sandboxWorkspaces: [], diff --git a/packages/eve/test/scenarios/agent-info-route.scenario.test.ts b/packages/eve/test/scenarios/agent-info-route.scenario.test.ts index e74769fdc6..45bb5bb698 100644 --- a/packages/eve/test/scenarios/agent-info-route.scenario.test.ts +++ b/packages/eve/test/scenarios/agent-info-route.scenario.test.ts @@ -83,7 +83,7 @@ describe("eve agent info route", () => { const payload = (await response.json()) as AgentInfoResponse; expect(payload.kind).toBe("eve-agent-info"); - expect(payload.version).toBe(3); + expect(payload.version).toBe(4); expect(payload.mode).toBe("development"); expect(payload.agent.model.id).toBe("openai/gpt-5.4"); expect(payload.instructions.static[0]?.content).toContain("precise assistant"); diff --git a/packages/eve/test/scenarios/compile-agent.scenario.test.ts b/packages/eve/test/scenarios/compile-agent.scenario.test.ts index b5b964f972..9fbae34202 100644 --- a/packages/eve/test/scenarios/compile-agent.scenario.test.ts +++ b/packages/eve/test/scenarios/compile-agent.scenario.test.ts @@ -57,6 +57,73 @@ function applicationOwnedEntries( } describe("compiler artifacts", () => { + it("loads the selected memory and derived wrapper from a generated map in a fresh process", async () => { + const { agentRoot, appRoot } = await createAppRoot( + "eve-compiler-memory-wrapper-", + APP_ROOT_OPTIONS, + ); + await mkdir(join(agentRoot, "memory"), { recursive: true }); + await writeFile( + join(agentRoot, "agent.mjs"), + 'import { defineAgent } from "eve";\nexport default defineAgent({ model: "openai/gpt-5.4" });\n', + ); + await writeFile(join(agentRoot, "instructions.md"), "Remember durable preferences."); + await writeFile( + join(agentRoot, "memory", "profile.mjs"), + [ + 'import { defineMemory } from "eve/memory";', + 'import { defineTool } from "eve/tools";', + "export default () => {", + " globalThis.__memoryFactoryCalls = (globalThis.__memoryFactoryCalls ?? 0) + 1;", + " return defineMemory({", + ' scope: "user_1",', + " provider: {", + ' recall: { "turn.started": async () => ({ messages: [{ id: "profile", content: "Likes tea" }] }) },', + " tools: async () => ({", + ' save: defineTool({ description: "Save profile.", inputSchema: {}, execute: async () => null }),', + " }),", + " },", + " });", + "};", + "", + ].join("\n"), + ); + + const compiled = await compileAgent({ startPath: appRoot }); + const memory = compiled.manifest.memories[0]!; + const wrapper = compiled.manifest.dynamicTools.find((entry) => entry.slug === "profile")!; + const script = [ + "const loaded = await import(process.argv[1]);", + "const modules = loaded.default.nodes.__root__.modules;", + "const memory = modules[process.argv[2]];", + "const wrapper = modules[process.argv[3]];", + "const memoryDefinition = await memory.default();", + "console.log(JSON.stringify({", + ' memory: typeof memoryDefinition.provider?.recall?.["turn.started"] === "function",', + " memoryFactoryCalls: globalThis.__memoryFactoryCalls,", + ' wrapper: typeof wrapper.default?.events?.["turn.started"] === "function",', + "}));", + ].join("\n"); + const loaded = await runFile( + process.execPath, + [ + "--input-type=module", + "--eval", + script, + compiled.paths.moduleMapPath, + memory.sourceId, + wrapper.sourceId, + ], + { cwd: appRoot }, + ); + + expect(JSON.parse(loaded.stdout)).toEqual({ + memory: true, + memoryFactoryCalls: 1, + wrapper: true, + }); + }); + it("uses the framework default model when agent.ts is omitted", async () => { const { agentRoot, appRoot } = await createAppRoot( "eve-compiler-default-model-", @@ -198,7 +265,7 @@ describe("compiler artifacts", () => { sourceId: "instructions.md", }, ], - version: 14, + version: 15, }); const compiledArtifact = normalizeArtifactValue( JSON.parse(compiledManifestText) as CompiledAgentManifest, diff --git a/packages/eve/test/scenarios/mounted-extension-installed.scenario.test.ts b/packages/eve/test/scenarios/mounted-extension-installed.scenario.test.ts index db13b1ec6c..a1ac986833 100644 --- a/packages/eve/test/scenarios/mounted-extension-installed.scenario.test.ts +++ b/packages/eve/test/scenarios/mounted-extension-installed.scenario.test.ts @@ -249,7 +249,7 @@ describe("mounted extension installed under node_modules", () => { ); expect( JSON.parse(extensionFiles[`node_modules/${PACKAGE_NAME}/dist/extension/_manifest.json`]!), - ).toMatchObject({ requires: { channel: 9, schedule: 3, subagent: 3 } }); + ).toMatchObject({ requires: { channel: 9, schedule: 3, subagent: 4 } }); const app = await scenarioApp({ name: "mounted-extension-installed", installDependencies: true, diff --git a/packages/eve/test/tui-client/lib/server.ts b/packages/eve/test/tui-client/lib/server.ts index fa3807b01b..9b17df8352 100644 --- a/packages/eve/test/tui-client/lib/server.ts +++ b/packages/eve/test/tui-client/lib/server.ts @@ -250,7 +250,7 @@ async function waitForAgentServer(input: { /** Reads the agent name from a versioned `/eve/v1/info` response. */ export function getAgentNameFromInfoPayload(payload: unknown): string | undefined { - if (!isRecord(payload) || payload.kind !== "eve-agent-info" || payload.version !== 3) { + if (!isRecord(payload) || payload.kind !== "eve-agent-info" || payload.version !== 4) { return undefined; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 073e392094..2aa1906779 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -708,6 +708,28 @@ importers: specifier: 'catalog:' version: 7.0.2 + e2e/fixtures/agent-memory: + dependencies: + '@eve-e2e/config': + specifier: workspace:* + version: link:../e2e-config + '@workflow/world-postgres': + specifier: 'catalog:' + version: 5.0.0-beta.35(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(@upstash/redis@1.38.0)(sql.js@1.14.1)(supports-color@10.2.2)(typescript@7.0.2) + eve: + specifier: workspace:* + version: link:../../../packages/eve + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 25.9.1 + typescript: + specifier: 'catalog:' + version: 7.0.2 + e2e/fixtures/agent-model: dependencies: '@eve-e2e/config': diff --git a/research/first-class-memory.md b/research/first-class-memory.md index 7d496506d2..629a5d8d1e 100644 --- a/research/first-class-memory.md +++ b/research/first-class-memory.md @@ -18,7 +18,7 @@ prompt. Each memory definition binds a provider to an application namespace, a trusted scope, and a recall visibility policy. It may also describe the slot's purpose to the model through provider tool descriptions. The provider contract -has three methods: +has three surfaces: - `recall` returns messages that eve applies to the slot's recalled context in durable history as user-role context. A message with an `id` inserts or @@ -26,11 +26,11 @@ has three methods: - `capture` observes history before compaction and after a completed turn. - `tools` contributes model tools bound to the active memory scope. -eve calls each method at fixed boundaries. Recall and capture receive a -discriminated `phase`, current turn coordinates, a stable operation ID, and the -locked memory scope resolved for the slot. Tools are resolved once after -turn-start recall through the same durable dynamic-capability machinery as a -`turn.started` `defineDynamic` tool resolver. +eve calls recall and capture handlers at fixed boundaries. Each handler +receives a boundary-specific context with current turn coordinates, a stable +operation ID, and the locked memory scope resolved for the slot. Tools are +resolved once after turn-start recall through the same durable +dynamic-capability machinery as a `turn.started` `defineDynamic` tool resolver. Memory definitions and provider tools compile through eve's canonical source graph. Each selected memory slot retains its direct binding and induces one @@ -41,10 +41,10 @@ module-map, dynamic-tool, approval, authorization, and replay paths. Memory does not add a runtime tool contribution seam or a second executable registry. ```text -turn.started ---> recall(phase: "turn.started") ---> tools -compaction.requested ---> capture(phase: "compaction.requested") -compaction.completed ---> recall(phase: "compaction.completed") -turn.completed ---> capture(phase: "turn.completed") +turn.started ---> recall["turn.started"] ---> tools +compaction.requested ---> capture["compaction.requested"] +compaction.completed ---> recall["compaction.completed"] +turn.completed ---> capture["turn.completed"] ``` eve owns namespace and scope resolution, invocation order, recall validation @@ -576,40 +576,38 @@ interface MemoryOperationContext extends SessionContext { }; } -type MemoryRecallContext = MemoryOperationContext & - ( - | { - readonly phase: "turn.started"; - readonly turn: MemoryTurnContext; - readonly compaction?: never; - } - | { - readonly phase: "compaction.completed"; - /** Null for standalone manual compaction. */ - readonly turn: MemoryTurnContext | null; - readonly compaction: { - readonly modelId: string; - }; - } - ); - -type MemoryCaptureContext = MemoryOperationContext & - ( - | { - readonly phase: "compaction.requested"; - /** Null for standalone manual compaction. */ - readonly turn: MemoryTurnContext | null; - readonly compaction: { - readonly modelId: string; - readonly usageInputTokens: number | null; - }; - } - | { - readonly phase: "turn.completed"; - readonly turn: MemoryTurnContext; - readonly compaction?: never; - } - ); +interface MemoryTurnStartedContext extends MemoryOperationContext { + readonly turn: MemoryTurnContext; +} + +interface MemoryCompactionCompletedContext extends MemoryOperationContext { + /** Null for standalone manual compaction. */ + readonly turn: MemoryTurnContext | null; + readonly compaction: { + readonly modelId: string; + }; +} + +interface MemoryCompactionRequestedContext extends MemoryOperationContext { + /** Null for standalone manual compaction. */ + readonly turn: MemoryTurnContext | null; + readonly compaction: { + readonly modelId: string; + readonly usageInputTokens: number | null; + }; +} + +interface MemoryTurnCompletedContext extends MemoryOperationContext { + readonly turn: MemoryTurnContext; +} + +type MemoryRecallHandler = ( + context: TContext, +) => MemoryRecallResult | Promise; + +type MemoryCaptureHandler = ( + context: TContext, +) => void | Promise; interface MemoryToolsContext extends DynamicResolveContext { readonly turn: MemoryTurnContext; @@ -623,11 +621,17 @@ interface MemoryToolsContext extends DynamicResolveContext { type MemoryToolSet = Readonly>; interface MemoryProvider { - recall(context: MemoryRecallContext): MemoryRecallResult | Promise; + readonly recall: { + readonly "turn.started": MemoryRecallHandler; + readonly "compaction.completed"?: MemoryRecallHandler; + }; - capture?(context: MemoryCaptureContext): void | Promise; + readonly capture?: { + readonly "compaction.requested"?: MemoryCaptureHandler; + readonly "turn.completed"?: MemoryCaptureHandler; + }; - tools?(context: MemoryToolsContext): MemoryToolSet | null | Promise; + readonly tools?: (context: MemoryToolsContext) => Promise; } ``` @@ -688,10 +692,11 @@ latest state: import { defineMemoryProvider } from "eve/memory"; const auditMemory = defineMemoryProvider({ - async recall(ctx) { - if (ctx.phase !== "turn.started") return null; - const note = await takeUnreadNote(ctx.memory.scope.key); - return note === null ? null : { messages: [{ content: note }] }; + recall: { + async "turn.started"(ctx) { + const note = await takeUnreadNote(ctx.memory.scope.key); + return note === null ? null : { messages: [{ content: note }] }; + }, }, }); ``` @@ -708,12 +713,13 @@ previous occupant of each position: import { defineMemoryProvider } from "eve/memory"; const retrievalMemory = defineMemoryProvider({ - async recall(ctx) { - if (ctx.phase !== "turn.started") return null; - const hits = await search(ctx.memory.scope.key, ctx.turn.input, { limit: 3 }); - return { - messages: hits.map((hit, rank) => ({ id: `rank:${rank}`, content: hit.text })), - }; + recall: { + async "turn.started"(ctx) { + const hits = await search(ctx.memory.scope.key, ctx.turn.input, { limit: 3 }); + return { + messages: hits.map((hit, rank) => ({ id: `rank:${rank}`, content: hit.text })), + }; + }, }, }); ``` @@ -789,10 +795,10 @@ competence, is the design driver. ### Turn-start recall After eve admits and normalizes a new turn, it resolves each memory scope and, -for a non-null scope, its namespace. It then calls `recall` with -`phase: "turn.started"` for every active slot. The context contains the -zero-based turn sequence, stable turn ID, normalized input, and the projected -history before the turn, including prior visible recalled context. +for a non-null scope, its namespace. It then calls the required +`recall["turn.started"]` handler for every active slot. The context contains +the zero-based turn sequence, stable turn ID, normalized input, and the +projected history before the turn, including prior visible recalled context. Every active slot resolves independently against that same pre-recall view. Each result is normalized and validated, and the whole turn-wide batch commits @@ -810,10 +816,10 @@ or use the current input as a retrieval query on every turn. ### Compaction capture, canonicalization, and recall Before automatic or manual compaction rewrites history, eve calls an -implemented `capture` method with `phase: "compaction.requested"`. The provider -receives the projected pre-rewrite history and the compaction model and usage -metadata. A provider may persist a checkpoint, extract facts the summary could -omit, or do nothing. +implemented `capture["compaction.requested"]` handler. The provider receives +the projected pre-rewrite history and the compaction model and usage metadata. +A provider may persist a checkpoint, extract facts the summary could omit, or +do nothing. Compaction is the canonicalization boundary for memory records. Trusted internal code partitions every attributed memory record — across every slot, @@ -830,12 +836,12 @@ prompt stays constant, eve triggers canonicalization on raw attributed-record growth independently of visible prompt size. eve never silently summarizes, evicts, or truncates provider items. -After a checkpoint is durably appended, eve calls `recall` with -`phase: "compaction.completed"`. The provider receives the settled +After a checkpoint is durably appended, eve calls an implemented +`recall["compaction.completed"]` handler. The provider receives the settled post-compaction projected history and may return fresh messages, applied -through the same atomic path. Identical retained items are no-ops. The call occurs -after every successful automatic or manual compaction, even when the provider -skipped ordinary turn-start recall. +through the same atomic path. Identical retained items are no-ops. The handler +runs after every successful automatic or manual compaction when registered, +even when the provider skipped ordinary turn-start recall. Provider tools are not resolved during a standalone compaction because no model call follows that boundary. @@ -912,11 +918,11 @@ replaces the parked call's captured scope values with another turn's scope. ### Completed-turn capture -After a turn reaches `turn.completed`, eve calls an implemented `capture` -method with `phase: "turn.completed"`. The provider receives the completed turn +After a turn reaches `turn.completed`, eve calls an implemented +`capture["turn.completed"]` handler. The provider receives the completed turn input and the settled projected history, including the assistant response and -tool results. The method does not run for failed, cancelled, input-deferred, or -adapter-consumed turns. +tool results. The handler does not run for failed, cancelled, input-deferred, +or adapter-consumed turns. Completed-turn capture is a semantic memory boundary, not an instrumentation export. It does not receive token usage, provider cost, latency, trace @@ -1109,8 +1115,9 @@ recall visibility. Mounted extensions cannot contribute memory slots. or deployment suffixes. - One scope lock applies to every provider call, recalled record, model step, and durable tool continuation in an operation. -- Recall and capture phases identify their exact lifecycle boundary. Every - provider context includes projected history and a replay-stable operation ID. +- Nested recall and capture handler keys identify the exact lifecycle boundary. + Every provider context includes projected history and a replay-stable + operation ID. - Raw durable history is storage-only. Every message-bearing consumer receives one canonical scope projection with attribution stripped; no authored callback or model boundary sees hidden items or eve-owned metadata. @@ -1223,8 +1230,8 @@ checks remain the merge gate. M2 must not begin on the pre-M1 base. Slack privacy thread](https://github.com/vercel/eve/pull/1581#discussion_r3807248748). -- [x] **Keep settled-turn telemetry out of memory.** `capture` with - `phase: "turn.completed"` receives completed input and projected history, +- [x] **Keep settled-turn telemetry out of memory.** + `capture["turn.completed"]` receives completed input and projected history, but not usage, cost, latency, trace identifiers, or unsuccessful outcomes. Instrumentation owns that data and can be correlated through `session.id` plus `turn.id`. `compaction.requested` retains its input-token