feat(experimental): Session API — messages, context blocks, compaction, search#1166
feat(experimental): Session API — messages, context blocks, compaction, search#1166mattzcarey wants to merge 6 commits intomainfrom
Conversation
…n, search Session API for persistent agent memory: - Session: tree-structured messages (branching via parent_id), non-destructive compaction overlays, context blocks with frozen system prompt, FTS5 search - AgentSessionProvider: SQLite-backed provider with session_id scoping for multi-session isolation within shared tables - AgentContextProvider: key-value block storage in SQLite (memory, todos, etc) - ContextBlocks: frozen snapshot system prompt (hermes-style plain text format), lazy loading, update_context tool with live usage feedback - session.freezeSystemPrompt() — render once, persist to SQLite, survives DO eviction - session.tools() — returns update_context tool for AI block writes - session.replaceContextBlock/appendContextBlock — direct block access - truncateOlderMessages — read-time tool output truncation - createCompactFunction — reference compaction (head/tail protection, structured summary) - 28 tests: frozen prompt, block writes, tool execute, provider ops, branching, compaction overlays, FTS search, persistence
|
packages/agents/src/experimental/memory/session/providers/agent.ts
Outdated
Show resolved
Hide resolved
| this.agent.sql` | ||
| INSERT OR IGNORE INTO assistant_messages (id, session_id, parent_id, role, message) | ||
| VALUES (${message.id}, ${this.sessionId}, ${parent}, ${message.role}, ${json}) | ||
| `; | ||
| this.indexFTS(message); |
There was a problem hiding this comment.
🟡 FTS5 indexing creates duplicate entries on idempotent append, violating documented contract
appendMessage uses INSERT OR IGNORE for the main table (line 170), correctly skipping duplicates. However, indexFTS is called unconditionally on line 173, regardless of whether the INSERT actually inserted a row. Inside indexFTS (line 277-280), INSERT OR REPLACE INTO assistant_fts is used — but FTS5 virtual tables don't enforce uniqueness on UNINDEXED columns like id. The only conflict trigger would be an explicit rowid collision, but no rowid is specified, so each call always creates a new FTS row. This means calling appendMessage twice with the same message ID creates duplicate FTS entries, causing searchMessages to return duplicate results. This violates the documented interface contract at provider.ts:50-51: "Idempotent — same message.id twice is a no-op."
Was this helpful? React with 👍 or 👎 to provide feedback.
Session.create(agent) returns a Session directly — no .build() needed. Builder methods (withContext, withCachedPrompt, forSession) configure the session before first use. Providers are resolved lazily in _ensureReady() so chain order doesn't matter. - withContext: auto-creates AgentContextProvider for writable blocks - withCachedPrompt: auto-creates prompt store provider - forSession: namespaces all auto-created provider keys by session ID - Fix empty DO binding in wrangler.jsonc that broke test runner
…s export - Add ensureTable() call in addCompaction (could fail if called before any read) - Delete FTS entries in deleteMessages (was leaving stale search results) - Add package.json export for agents/experimental/memory/utils
…ompat - Remove unused imports (MessageQueryOptions, estimateStringTokens, etc.) - Fix AI SDK v6: parameters → inputSchema, maxSteps → stopWhen - Cast tool-result part via unknown for v6 UIMessage types - Add ToolExecuteFn type alias for cleaner test casts - Move session-memory example update to this PR (fixes typecheck) - Remove invalid Button props (shape, loading)
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
| "./experimental/memory/utils": { | ||
| "types": "./dist/experimental/memory/utils/index.d.ts", | ||
| "import": "./dist/experimental/memory/utils/index.js", | ||
| "require": "./dist/experimental/memory/utils/index.js" | ||
| }, |
There was a problem hiding this comment.
🔴 Missing changeset for new public API exports in packages/agents
The repository's AGENTS.md states: "Changes to packages/ that affect the public API or fix bugs need a changeset" and "Every new public export needs: an entry in package.json exports, a build entry in scripts/build.ts, and a changeset." This PR adds a new ./experimental/memory/utils export path to packages/agents/package.json (line 154-158), adds a corresponding build entry in scripts/build.ts (line 21), and significantly modifies the ./experimental/memory/session exports. No changeset file was created in .changeset/.
Was this helpful? React with 👍 or 👎 to provide feedback.
- getPathLength: validate leafId against session_id - latestLeafRow: scope LEFT JOIN children to same session - needsCompaction: use getHistory().length (post-compaction count) - compact example: skip synthetic compaction_ IDs in removed set
| async chat(message: string): Promise<UIMessage> { | ||
| this.session.appendMessage({ | ||
| id: `user-${crypto.randomUUID()}`, | ||
| role: "user", | ||
| parts: [{ type: "text", text: message }] | ||
| }); |
There was a problem hiding this comment.
🟡 Client and server create user messages with different IDs, causing ID instability on reload
The client creates an optimistic user message with id: \user-${crypto.randomUUID()}`atclient.tsx:134and adds it to local state. The server'schat()method atserver.ts:57 creates its own user message with a **different** UUID. The old code passed the client's message ID to the server (agent.call("chat", [text, userMsg.id])), but the new code only sends the text (agent.call("chat", [text])). This means after any reload — reconnect (client.tsx:119), compact (client.tsx:171`), or page refresh — the user message IDs change because they're loaded from the server's versions. React re-mounts all user message elements due to key changes, and any code relying on stable message IDs across client and server will break.
Prompt for agents
In experimental/session-memory/src/server.ts, the chat() method should accept an optional message ID from the client to keep user message IDs in sync, restoring the old behavior.
Change the chat method signature at line 55 to accept a second parameter:
async chat(message: string, messageId?: string): Promise<UIMessage>
Then on line 57, use the client-provided ID if available:
id: messageId ?? `user-${crypto.randomUUID()}`
In experimental/session-memory/src/client.tsx at line 140, pass the user message ID to the server:
const msg = await agent.call<UIMessage>("chat", [text, userMsg.id]);
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
Sessionclass: tree-structured messages, non-destructive compaction overlays, context blocks with frozen system prompt, FTS5 searchAgentSessionProvider: SQLite-backed withsession_idscoping for multi-sessionAgentContextProvider: key-value block storage in SQLitesession.freezeSystemPrompt()— render once, persist, survives DO evictionsession.tools()—update_contexttool for AI block writescreateCompactFunction— reference compaction with head/tail protectionSession.create(agent).withContext(...).withCachedPrompt()— auto-wires SQLite providers, no manual provider construction needed.forSession(id)namespaces all provider keys automaticallyStack
1/4 — this PR
2/4 — #1167 SessionManager (depends on this)
3/4 — #1168 Examples (depends on 2)
4/4 — #1169 Think integration (depends on 3)
Test plan