feat(memory): unified Session API with context blocks, compaction, FTS search#1159
feat(memory): unified Session API with context blocks, compaction, FTS search#1159mattzcarey wants to merge 35 commits intomainfrom
Conversation
Adds Context Memory as the second tier of the experimental memory system. Context stores labeled text blocks (personality, preferences, tasks) that persist across sessions and can be injected into AI prompts. Key features: - Readonly blocks (SOUL files the AI cannot modify) - maxTokens enforcement via token estimation heuristic - AI tool integration via tools() → ToolSet - System prompt rendering via toString() - Predefined blocks with defaults
Remove separate Context class. Context blocks (MEMORY, USER, SOUL, etc.) are now managed by Session directly with per-block ContextBlockProvider. Add FTS5 content-sync search, frozen snapshot for prompt caching, and update_context AI tool.
|
packages/agents/src/experimental/memory/session/providers/agent.ts
Outdated
Show resolved
Hide resolved
| InnerSubAgent, | ||
| CallbackSubAgent | ||
| } from "./sub-agent"; | ||
| ======= | ||
| >>>>>>> cafa785b (Unify Session + Context into single Session API) |
There was a problem hiding this comment.
🟡 deleteMessages does not clean up FTS index, leaving deleted messages searchable
deleteMessages() removes rows from cf_agents_session_messages but does not remove the corresponding entries from cf_agents_session_fts. In contrast, clearMessages() at packages/agents/src/experimental/memory/session/providers/agent.ts:186 does clean up the FTS table. This means deleted messages remain searchable via searchMessages(), returning stale results that reference messages that no longer exist.
Was this helpful? React with 👍 or 👎 to provide feedback.
- createCompactFunction() — full hermes-style compaction algorithm: head/tail protection, token-budget boundaries, tool pair alignment, LLM summarization with structured format, iterative summary updates, orphaned tool pair sanitization - Frozen snapshot fix — toSystemPrompt() now returns the same string on every call (first call captures, subsequent calls return cached). refreshSystemPrompt() to re-render at session boundaries. - Export all compaction helpers: sanitizeToolPairs, alignBoundary*, findTailCutByTokens, computeSummaryBudget, buildSummaryPrompt
SessionManager: - compactAndSplit() — end current session, create new one seeded with summary, linked via parent_session_id. Nothing deleted, additive only. - search() — FTS5 full-text search across all messages in the DO - addUsage() — record token usage and cost per session SessionStorage: - Extended assistant_sessions with parent_session_id, model, source, input_tokens, output_tokens, estimated_cost, end_reason - Added assistant_messages_fts (FTS5) populated on append/upsert - searchMessages(), addUsage(), endSession() methods
SessionProvider now supports tree-structured messages: - appendMessage(msg, parentId) — tree insert with optional parent - getHistory(leafId) — recursive CTE walk from leaf to root - getLatestLeaf() — most recent message with no children - getBranches(messageId) — get children (branch points) - getPathLength() — count messages on current branch Compaction records (non-destructive): - addCompaction(summary, fromId, toId) — overlay, not delete - getCompactions() — list all compaction records - Applied at read time in getHistory() — original messages stay intact - needsCompaction(maxMessages) — check if branch is too long Session API now has feature parity with Think's SessionManager. Think can replace its own SessionManager with this.
… API Delete think's own SessionStorage (storage.ts, 482 lines). SessionManager now wraps agents/experimental/memory/session: - Each session gets its own Session + AgentSessionProvider - Branching, compaction, FTS search delegated to Session API - Session metadata (model, source, tokens, cost, parent_session_id) managed in assistant_sessions table - compactAndSplit(), search(), addUsage() preserved - All existing Think API surface unchanged (backward compatible) Net: -613 lines. Think is now a consumer of the Session API.
packages/think/src/session/index.ts
Outdated
| upsert(sessionId: string, message: UIMessage, parentId?: string): string { | ||
| const resolvedParent = | ||
| parentId ?? this._storage.getLatestLeaf(sessionId)?.id ?? null; | ||
| const id = message.id || crypto.randomUUID(); | ||
| this._storage.upsertMessage(id, sessionId, resolvedParent, message); | ||
| return id; | ||
| const session = this._getSession(sessionId); | ||
| session.appendMessage(message, parentId); | ||
| this._updateTimestamp(sessionId); | ||
| return message.id; | ||
| } |
There was a problem hiding this comment.
🔴 SessionManager.upsert() uses INSERT OR IGNORE instead of INSERT ... ON CONFLICT UPDATE, breaking incremental persistence
The new upsert() method calls session.appendMessage() which delegates to AgentSessionProvider.appendMessage() that uses INSERT OR IGNORE (packages/agents/src/experimental/memory/session/providers/agent.ts:202). This silently discards updates when the message ID already exists. The old code used INSERT INTO ... ON CONFLICT(id) DO UPDATE SET content = ${content} which would update on duplicate. The Think._persistAssistantMessage() at packages/think/src/think.ts:915 calls this.sessions.upsert() to incrementally persist a streaming assistant message — it first inserts the partial message, then updates it with more content as tokens arrive. With the new code, only the first partial message is persisted; all subsequent updates are silently ignored, resulting in incomplete/truncated assistant messages being stored.
The Session class doesn't expose upsertMessage
The provider has upsertMessage() (packages/agents/src/experimental/memory/session/providers/agent.ts:225-237) which correctly uses ON CONFLICT(id) DO UPDATE, but the Session wrapper class (packages/agents/src/experimental/memory/session/session.ts) never exposes it. The SessionManager has no way to perform a true upsert through the Session API.
Prompt for agents
The Session class at packages/agents/src/experimental/memory/session/session.ts needs to expose a upsertMessage() method that delegates to this.storage.upsertMessage(). The provider at packages/agents/src/experimental/memory/session/providers/agent.ts:225-237 already has the correct implementation using ON CONFLICT(id) DO UPDATE.
1. In packages/agents/src/experimental/memory/session/session.ts, add a method:
upsertMessage(message: UIMessage, parentId?: string | null): void {
this.storage.upsertMessage(message, parentId);
}
2. In packages/think/src/session/index.ts, change the upsert() method at line 205-210 to call session.upsertMessage() instead of session.appendMessage():
upsert(sessionId: string, message: UIMessage, parentId?: string): string {
const session = this._getSession(sessionId);
session.upsertMessage(message, parentId);
this._updateTimestamp(sessionId);
return message.id;
}
Was this helpful? React with 👍 or 👎 to provide feedback.
| this.agent.sql` | ||
| CREATE TABLE IF NOT EXISTS cf_agents_session_messages ( | ||
| id TEXT PRIMARY KEY, | ||
| parent_id TEXT, | ||
| role TEXT NOT NULL, | ||
| message TEXT NOT NULL, | ||
| created_at DATETIME DEFAULT CURRENT_TIMESTAMP | ||
| ) | ||
| `; |
There was a problem hiding this comment.
🔴 Messages table has no session_id column, causing all sessions to share messages globally
The new cf_agents_session_messages table (packages/agents/src/experimental/memory/session/providers/agent.ts:31-37) has no session_id column, unlike the old assistant_messages table which had session_id TEXT NOT NULL. When SessionManager._getSession() creates multiple Session instances for different session IDs, they all share the same AgentSessionProvider SQL backend and the same table. This means:
clearMessages(sessionId)deletes ALL messages across all sessions (agent.ts:256)getHistory(sessionId)finds the global latest leaf, not per-session (agent.ts:342-350)getMessages()returns messages from all sessionsneedsCompaction()counts messages globally- Compactions are also global (no session_id in
cf_agents_session_compactions)
The Think class uses SessionManager with multiple sessions (create, switch, list). With this bug, switching sessions would show messages from all sessions, and clearing one session would wipe everything.
Prompt for agents
The cf_agents_session_messages table in packages/agents/src/experimental/memory/session/providers/agent.ts needs a session_id column to support multi-session usage via SessionManager.
Option A (match old schema): Add a session_id TEXT column to cf_agents_session_messages and cf_agents_session_compactions. Thread session_id through the SessionProvider interface and all queries (getHistory, getLatestLeaf, clearMessages, getCompactions, etc.) so they filter by session_id.
Option B (session-scoped provider): Make AgentSessionProvider accept a sessionId in its constructor and scope all queries by it. Then SessionManager._getSession() would pass the sessionId when constructing each provider.
All queries that currently scan the full table (getLatestLeafRow, getMessages, clearMessages, getCompactions, etc.) must be updated to filter by session_id. The old storage.ts had session_id filtering on every query — refer to the deleted packages/think/src/session/storage.ts for the correct scoping pattern.
Was this helpful? React with 👍 or 👎 to provide feedback.
1. assembleContext() — checks needsCompaction(), calls onCompact(), reloads history (with compaction overlays applied). Removed pruneMessages() — compaction handles context management now. 2. onCompact() — new override point. Default no-op. Developers implement to generate summary + compactAndSplit(). 3. Usage tracking — captures promptTokens/completionTokens from stream finish events, persists via sessions.addUsage(). 4. Auto session titles — first user message text (up to 60 chars) used as session name instead of "New Chat". 5. Removed _enforceMaxPersistedMessages — it deleted messages without compacting. Context should never be silently lost. maxPersistedMessages deprecated in favor of maxContextMessages.
packages/think/src/session/index.ts
Outdated
| upsert(sessionId: string, message: UIMessage, parentId?: string): string { | ||
| const resolvedParent = | ||
| parentId ?? this._storage.getLatestLeaf(sessionId)?.id ?? null; | ||
| const id = message.id || crypto.randomUUID(); | ||
| this._storage.upsertMessage(id, sessionId, resolvedParent, message); | ||
| return id; | ||
| const session = this._getSession(sessionId); | ||
| session.appendMessage(message, parentId); | ||
| this._updateTimestamp(sessionId); | ||
| return message.id; |
There was a problem hiding this comment.
🟡 SessionManager.upsert() silently drops content updates (INSERT OR IGNORE instead of true upsert)
The upsert() method delegates to append() which calls appendMessage() using INSERT OR IGNORE (packages/agents/src/experimental/memory/session/providers/agent.ts:162). If called with an existing message ID and different content, the update is silently discarded. The previous implementation in SessionStorage.upsertMessage() used INSERT ... ON CONFLICT(id) DO UPDATE SET content = ${content}, which actually updated existing rows.
This is called from Think._persistAssistantMessage at packages/think/src/think.ts:1000. Currently safe because message IDs are unique UUIDs, but the public API contract is broken — callers expecting upsert to update existing messages will silently lose data.
Was this helpful? React with 👍 or 👎 to provide feedback.
| addCompaction( | ||
| summary: string, | ||
| fromMessageId: string, | ||
| toMessageId: string | ||
| ): StoredCompaction { | ||
| const id = crypto.randomUUID(); | ||
| this.agent.sql` | ||
| INSERT INTO cf_agents_session_compactions (id, summary, from_message_id, to_message_id) | ||
| VALUES (${id}, ${summary}, ${fromMessageId}, ${toMessageId}) | ||
| `; |
There was a problem hiding this comment.
🟡 addCompaction missing ensureTable() call, will fail if called first
AgentSessionProvider.addCompaction() at line 168 does not call ensureTable(), unlike every other public method in the class. If addCompaction() is the first method called on a provider instance, the cf_agents_session_compactions table won't exist and the SQL INSERT will fail with a table-not-found error.
Was this helpful? React with 👍 or 👎 to provide feedback.
- TestSessionAgent: add branching (appendMessageWithParent, getHistory, getBranches, getLatestLeaf), compaction records (addCompaction, getCompactions, needsCompaction), and search methods - TestSessionAgentWithContext: new agent testing context blocks with frozen snapshot (toSystemPrompt/refreshSystemPrompt), setBlock, getBlock, tools() - Fix merge conflict markers in agents/index.ts - Register TestSessionAgentWithContext in worker.ts and wrangler.jsonc
packages/think/src/session/index.ts
Outdated
| deleteMessage(messageId: string): void { | ||
| // Delete across all sessions — messages table is shared | ||
| for (const session of this._sessions.values()) { | ||
| session.deleteMessages([messageId]); | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 SessionManager.deleteMessage/deleteMessages silently no-ops if no sessions are cached
The deleteMessage() and deleteMessages() methods iterate over this._sessions.values() (the in-memory cache of loaded Session objects). If no sessions have been loaded via _getSession() yet, the cache is empty and the loop body never executes, causing the delete to silently do nothing.
The old code called this._storage.deleteMessage(id) directly, which always executed against the database. The new code has a dependency on sessions being previously cached in memory.
Affected code
deleteMessage(messageId: string): void {
for (const session of this._sessions.values()) {
session.deleteMessages([messageId]);
}
}If _sessions is empty (e.g., freshly constructed SessionManager where only create() and append() were called on a different session ID), the deletion is a no-op.
Was this helpful? React with 👍 or 👎 to provide feedback.
- SqliteBlockProvider: default durable storage for context blocks
using DO SQLite (cf_agents_context_blocks table)
- Think.getContextBlocks(): override to declare blocks (memory,
todos, soul, etc.) — auto-wired with SqliteBlockProvider
- getSystemPrompt() renders frozen context block snapshot by default
- onChatMessage() merges update_context tool automatically
- SessionManager.getSession() made public for Think integration
Usage:
```typescript
class MyAgent extends Think<Env> {
getContextBlocks() {
return [
{ label: "memory", description: "Learned facts", maxTokens: 1100 },
{ label: "soul", defaultContent: "You are helpful.", readonly: true },
];
}
}
// Agent gets update_context tool, frozen system prompt, durable blocks
```
Removed: - compact() + CompactFunction + CompactResult — destructive, wrong with branching. Use addCompaction() (overlay) or compactAndSplit() instead. - replaceMessages() — destructive bulk replace, not needed. - appendMessages() — flat batch API, replaced by appendMessage(msg, parentId). - getMessages() / getLastMessages() / getOlderMessages() — flat APIs, replaced by getHistory(leafId) which walks the tree and applies compaction overlays. - microCompaction (Session option + utils) — was mutating stored messages. Compaction overlays handle context management non-destructively. - MicroCompactionRules, CompactionConfig types — dead. - _persistedMessageCache in Think — idempotency handled by INSERT OR IGNORE. - maxPersistedMessages in Think — was silently deleting context. - TestSessionAgentNoMicroCompaction, TestSessionAgentCustomRules — dead test agents. Net: -529 lines. Session API is now tree-only, overlay-only, no destructive operations.
truncateOlderMessages() — truncates tool outputs and long text in older messages at read time. Stored messages stay intact. Called in Think's assembleContext() before sending to the LLM. Default: keep last 4 messages intact, truncate tool outputs to 500 chars, text to 10K chars in older messages.
session-memory example: - Context blocks (soul, memory, todos) with SqliteBlockProvider - Frozen system prompt via toSystemPrompt() - update_context tool for AI to modify blocks - Read-time truncation via truncateOlderMessages() - FTS search - Removed: compact(), getMessages(), append() assistant example (Think): - getContextBlocks() replaces getSystemPrompt() - Soul block (readonly) + memory block (writable, persisted) - Agent gets update_context tool automatically
Auto-compacts at 50 messages: summarize middle messages with LLM using buildSummaryPrompt(), overlay via addCompaction(). Old messages stay in storage, getHistory() returns summary instead. Also callable manually via compact().
One Orchestrator agent manages multiple independent chat sessions, each a Think facet with its own SQLite, context blocks, and memory. - createChat(name) — spin up a new chat session - chat(chatId, message) — send message to a specific chat - getHistory(chatId) — get conversation for a chat - searchAll(query) — FTS search across ALL chats - deleteChat(chatId) — remove a chat and its facet Each ChatSession has: - Context blocks (soul + memory) persisted in facet SQLite - update_context tool for AI to save learned facts - onCompact() with structured summary + session split
- Debug tab shows messages that will be sent to the LLM (getHistory) with compaction overlays visible - Compaction summaries styled differently in chat view - Fixed chat() call signature (single arg) - Refresh debug on compact
| role: "assistant", | ||
| parts: [{ type: "text", text: `[Previous conversation summary]\n${comp.summary}` }], | ||
| createdAt: new Date(), | ||
| } as UIMessage); |
There was a problem hiding this comment.
🟡 Compaction summary role changed from 'system' to 'assistant', breaking existing behavior
The new applyCompactions() in AgentSessionProvider creates compaction summary messages with role: "assistant", while the old SessionStorage._applyCompactions() (now deleted) used role: "system". This changes how the LLM interprets compacted conversation context — system messages are treated differently from assistant messages by most LLMs. The existing test at packages/think/src/tests/assistant-session.test.ts:327 explicitly asserts expect(history[0].role).toBe("system"), confirming the old behavior was intentional.
Was this helpful? React with 👍 or 👎 to provide feedback.
- session.context.freezeSystemPrompt() — renders once, persists to SQLite, survives DO eviction. refreshSystemPrompt() re-renders after compaction. - session.context.tools() — async, loads blocks lazily, returns update_context tool with live usage % in responses - session.context.setBlock/getBlock/appendToBlock — direct context block access - AgentSessionProvider(agent, sessionId) — namespace via session_id column in shared tables for multi-session isolation - AgentContextProvider (renamed from SqliteBlockProvider) — used for block storage and prompt persistence - Plain text system prompt format (hermes-style separators, not XML) - Think: getSystemPrompt() now async, tools() async, removed maxPersistedMessages - Examples: session-memory (single session) + multi-session-agent (sidebar UI) - 28 new tests: frozen prompt, block writes, tool execute, session isolation, compaction isolation, prompt persistence, clear isolation - 766 total tests passing
- SessionManager extracted from Think into agents/experimental/memory/session - create/list/delete/rename sessions - compactAndSplit for session splitting with summary - search() across all sessions (shared FTS table) - session.tools() returns update_context + session_search - session_search: FTS across all sessions in the DO - update_context: writes to context blocks (from context.tools()) - Multi-session example rewritten to use SessionManager - AgentContextProvider renamed from SqliteBlockProvider - searchMessages searches all sessions (no session_id filter)
…k(), etc Move all context methods to top-level Session: - session.freezeSystemPrompt() / refreshSystemPrompt() - session.replaceContextBlock() / appendContextBlock() - session.getContextBlock() / getContextBlocks() - session.tools() — includes update_context + session_search No more session.context.* — everything is session.*
Manager: create, get, list, delete, rename, search. Session: messages, context blocks, compaction, tools. Manager owns registry metadata (id, name, created_at). Session has no identity — it's a stateless wrapper around provider + context.
…ies, AgentContextProvider - SessionManager: create/get/list/delete/rename + cross-session search - session.tools() includes session_search (FTS) + update_context - session.replaceContextBlock/appendContextBlock/getContextBlock proxies - AgentContextProvider get/set persistence - session.search scoped by sessionId, manager.search searches all - 36 session tests + 896 total passing
…rectly No more fake empty-session hack for cross-session search. Manager queries the shared FTS table directly via SqlProvider.
session.tools() → { update_context }
manager.tools() → { session_search }
Usage: tools: { ...await session.tools(), ...manager.tools() }
| private compactFn = createCompactFunction({ | ||
| summarize: (prompt) => | ||
| generateText({ model: this.getAI(), prompt }).then((r) => r.text), | ||
| protectHead: 1, | ||
| minTailMessages: 2, | ||
| tailTokenBudget: 100, | ||
| }); |
There was a problem hiding this comment.
🔴 Shared compactFn closure leaks previousSummary across sessions in MultiSessionAgent
createCompactFunction() captures previousSummary in a closure variable (packages/agents/src/experimental/memory/utils/compaction-helpers.ts:438). In MultiSessionAgent, a single compactFn instance is shared across all chat sessions (experimental/multi-session-agent/src/server.ts:43). When session A is compacted, previousSummary is set to session A's summary. When session B is later compacted, the LLM receives a prompt containing session A's summary under "PREVIOUS SUMMARY" (packages/agents/src/experimental/memory/utils/compaction-helpers.ts:323-328) along with session B's turns under "NEW TURNS TO INCORPORATE". The LLM will merge unrelated context from session A into session B's compaction summary, producing a corrupted result.
Prompt for agents
The `compactFn` at experimental/multi-session-agent/src/server.ts:43-49 is a single closure shared across all sessions. The `createCompactFunction` from packages/agents/src/experimental/memory/utils/compaction-helpers.ts stores `previousSummary` in the closure (line 438), which leaks between sessions.
Fix: Create a separate compactFn per session instead of sharing one instance. Move the `compactFn` creation into the `compact()` method at line 139, or maintain a Map<string, compactFn> keyed by chatId. Alternatively, modify `createCompactFunction` to accept `previousSummary` as an argument rather than storing it in closure state.
Was this helpful? React with 👍 or 👎 to provide feedback.
SessionManager API: - create(name) → SessionInfo - get(id) → SessionInfo | null - getSession(id) → Session (always returns) - list() → SessionInfo[] - append/appendAll/upsert/getHistory/clearMessages — convenience by ID - needsCompaction/addCompaction/compactAndSplit - addUsage — token tracking - search/tools — cross-session FTS Think's session/index.ts now re-exports from agents package. No more duplicate SessionManager implementations.
No migration needed — our tables weren't in prod, Think's existing deployments keep working.
| search(query: string, options?: { limit?: number }) { | ||
| const limit = options?.limit ?? 20; | ||
| return this.agent.sql<{ id: string; role: string; content: string }>` | ||
| SELECT id, role, content FROM assistant_fts | ||
| WHERE assistant_fts MATCH ${query} | ||
| ORDER BY rank LIMIT ${limit} | ||
| `.map((r) => ({ id: r.id, role: r.role, content: r.content, createdAt: "" })); | ||
| } |
There was a problem hiding this comment.
🔴 SessionManager.search() crashes if called before any session's tables are initialized
The SessionManager.search() method queries the assistant_fts virtual table directly via this.agent.sql, but this FTS table is only created lazily by AgentSessionProvider.ensureTable() — which runs on the first message operation (append, getHistory, etc.) for any session. SessionManager._ensureTable() only creates the assistant_sessions table, not the FTS table.
If search() is called before any session has been used (e.g., the user invokes searchAll in the multi-session example before creating any chats), the query will throw a SQL error: no such table: assistant_fts.
Affected callers
In experimental/multi-session-agent/src/server.ts:165-167:
@callable()
searchAll(query: string) {
return this.manager.search(query);
}The session_search tool's execute has a try-catch, so it only degrades (returns error string). But direct calls crash.
Prompt for agents
In packages/agents/src/experimental/memory/session/manager.ts, the search() method (lines 215-222) queries the assistant_fts table which is only created by AgentSessionProvider.ensureTable(). Fix this by either:
1. Having SessionManager._ensureTable() also create the assistant_fts virtual table (and assistant_messages, assistant_compactions tables), OR
2. Having search() trigger a getSession() call first to ensure tables exist, OR
3. Wrapping the search query in a try-catch that returns an empty array if the table doesn't exist.
Option 1 is the most robust fix. Add to _ensureTable() in manager.ts:
this.agent.sql`CREATE VIRTUAL TABLE IF NOT EXISTS assistant_fts USING fts5(id UNINDEXED, session_id UNINDEXED, role UNINDEXED, content, tokenize='porter unicode61')`;
Was this helpful? React with 👍 or 👎 to provide feedback.
| manager = new SessionManager(this, { | ||
| sessionOptions: { | ||
| context: [ | ||
| { | ||
| label: "soul", | ||
| description: "Agent identity", | ||
| defaultContent: "You are a helpful assistant with persistent memory. Use the update_context tool to save important facts.", | ||
| readonly: true, | ||
| }, | ||
| { | ||
| label: "memory", | ||
| description: "Learned facts — save important things here", | ||
| maxTokens: 1100, | ||
| provider: new AgentContextProvider(this, "memory"), | ||
| }, | ||
| ], | ||
| promptStore: new AgentContextProvider(this, "_system_prompt"), | ||
| }, | ||
| }); |
There was a problem hiding this comment.
🔴 Multi-session example shares context blocks and system prompt across all sessions
The MultiSessionAgent claims "Each session has its own messages, context blocks (memory), and compaction" (line 5), but the AgentContextProvider instances use global labels ("memory", "_system_prompt") that aren't scoped by session ID. All sessions created by the SessionManager share the same sessionOptions with the same provider instances, so writing to the "memory" block in one session overwrites it for all sessions.
The test code in packages/agents/src/tests/agents/multi-session.ts:22-25 correctly scopes labels by session ID (memory_${sessionId}, _prompt_${sessionId}), but the example doesn't. Since SessionManager.getSession() passes the same shared _options.sessionOptions to every Session, the promptStore and context providers are identical across sessions.
Prompt for agents
In experimental/multi-session-agent/src/server.ts, the SessionManager is configured with context block providers that use global labels (lines 36 and 39). These should be scoped per session.
However, the SessionManager API creates sessions via getSession() using shared sessionOptions, which makes per-session providers difficult with the current design. There are two approaches:
1. Remove the context/promptStore from SessionManager options and instead configure them when creating each Session manually (like the test code does in packages/agents/src/tests/agents/multi-session.ts:14-27).
2. Change SessionManager.getSession() to accept a factory function for sessionOptions that receives the sessionId, allowing per-session provider labels.
For the immediate fix, override getSession on the manager or create Sessions manually with scoped labels like:
provider: new AgentContextProvider(this, `memory_${sessionId}`)
promptStore: new AgentContextProvider(this, `_prompt_${sessionId}`)
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
Merges Context Memory (#1005) into Session as a single unified API — one class for conversation history, persistent context blocks, compaction, search, and AI tools.
Supersedes #1005.
What's new
Context blocks on Session
Persistent key-value blocks (MEMORY, USER, SOUL, todos, etc.) managed directly by Session. Each block has its own
ContextBlockProvider— storage is pluggable per block (R2, KV, SQLite, in-memory).Frozen system prompt snapshot
toSystemPrompt()captures once, returns the same string on every subsequent call. Block mutations viasetBlock()write to the provider immediately (durable) but don't change the snapshot. This preserves the LLM prefix cache across all turns in a session.Reference compaction implementation
createCompactFunction()implements hermes-style compaction:All helpers are exported individually for custom implementations:
sanitizeToolPairs,alignBoundaryForward,alignBoundaryBackward,findTailCutByTokens,computeSummaryBudget,buildSummaryPrompt.AI tool —
update_contextSingle tool for all writable blocks. Readonly blocks are visible in the system prompt but excluded from the tool. Usage indicator shows how full each block is.
FTS5 search
Full-text search across messages via
searchMessages()on the provider. Standalone FTS5 table populated on append with porter stemming.What changed
Removed
Contextclass,ContextProvider,AgentContextProvideragents/experimental/memory/contextexport pathModified
Session— added context blocks, frozen snapshot,tools(),search(),refreshSystemPrompt()SessionProvider— added optionalsearchMessages()methodAgentSessionProvider— added FTS5 table + search implementationSessionOptions(renamed fromSessionProviderOptions) — addedcontextfieldAdded
session/context.ts—ContextBlocksclass with frozen snapshotutils/compaction-helpers.ts— reference compaction, tool pair sanitization, boundary alignment