Skip to content

feat(memory): unified Session API with context blocks, compaction, FTS search#1159

Closed
mattzcarey wants to merge 35 commits intomainfrom
feat/unified-session-api
Closed

feat(memory): unified Session API with context blocks, compaction, FTS search#1159
mattzcarey wants to merge 35 commits intomainfrom
feat/unified-session-api

Conversation

@mattzcarey
Copy link
Contributor

@mattzcarey mattzcarey commented Mar 23, 2026

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).

const session = new Session(new AgentSessionProvider(this), {
  context: [
    { label: "memory", description: "Learned facts", maxTokens: 1100,
      provider: new R2BlockProvider(env.BUCKET, "memory.md") },
    { label: "user", description: "User preferences", maxTokens: 550,
      provider: new KVBlockProvider(env.KV, "user-profile") },
    { label: "soul", defaultContent: "You are helpful.", readonly: true },
  ]
});

await session.init(); // loads blocks from providers

Frozen system prompt snapshot

toSystemPrompt() captures once, returns the same string on every subsequent call. Block mutations via setBlock() write to the provider immediately (durable) but don't change the snapshot. This preserves the LLM prefix cache across all turns in a session.

// First call captures and freezes
const systemPrompt = session.toSystemPrompt();

// Same string on every subsequent call — prefix cache stable
const samePrompt = session.toSystemPrompt(); // === systemPrompt

// Agent updates a block mid-session — durable but prompt unchanged
await session.setBlock("memory", "User prefers dark mode");

// At next session boundary, re-render to pick up changes
session.refreshSystemPrompt();

Reference compaction implementation

createCompactFunction() implements hermes-style compaction:

  1. Protect head — first N messages kept intact
  2. Protect tail by token budget — walk backward accumulating tokens (default 20K), not fixed message count
  3. Align boundaries — never split tool call/result pairs
  4. Summarize middle — structured LLM prompt (Goal, Progress, Key Decisions, Relevant Files, Next Steps)
  5. Iterative updates — subsequent compactions pass the previous summary for incremental updates instead of regenerating
  6. Sanitize tool pairs — fix orphaned calls/results after compression
import { createCompactFunction } from "agents/experimental/memory/utils";

const session = new Session(new AgentSessionProvider(this), {
  compaction: {
    tokenThreshold: 100000,
    fn: createCompactFunction({
      summarize: (prompt) => generateText({ model, prompt }).then(r => r.text),
      tailTokenBudget: 20000,
    })
  }
});

All helpers are exported individually for custom implementations:
sanitizeToolPairs, alignBoundaryForward, alignBoundaryBackward, findTailCutByTokens, computeSummaryBudget, buildSummaryPrompt.

AI tool — update_context

Single 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.

const tools = { ...session.tools(), ...otherTools };
// Agent sees: update_context({ label: "memory", content: "New fact..." })

FTS5 search

Full-text search across messages via searchMessages() on the provider. Standalone FTS5 table populated on append with porter stemming.

const results = session.search("deployment error");

What changed

Removed

  • Separate Context class, ContextProvider, AgentContextProvider
  • agents/experimental/memory/context export path
  • Context test agents and test files

Modified

  • Session — added context blocks, frozen snapshot, tools(), search(), refreshSystemPrompt()
  • SessionProvider — added optional searchMessages() method
  • AgentSessionProvider — added FTS5 table + search implementation
  • SessionOptions (renamed from SessionProviderOptions) — added context field
  • Top-level memory index — exports unified API

Added

  • session/context.tsContextBlocks class with frozen snapshot
  • utils/compaction-helpers.ts — reference compaction, tool pair sanitization, boundary alignment

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.
@changeset-bot
Copy link

changeset-bot bot commented Mar 23, 2026

⚠️ No Changeset found

Latest commit: 721b849

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 3 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +52 to +56
InnerSubAgent,
CallbackSubAgent
} from "./sub-agent";
=======
>>>>>>> cafa785b (Unify Session + Context into single Session API)
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot Mar 23, 2026

Choose a reason for hiding this comment

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

🟡 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.

Open in Devin Review

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
@mattzcarey mattzcarey changed the title feat: unified Session API — context blocks, FTS search, frozen snapshots feat(memory): unified Session API with context blocks, compaction, FTS search Mar 23, 2026
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
devin-ai-integration[bot]

This comment was marked as resolved.

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.
devin-ai-integration[bot]

This comment was marked as resolved.

… 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.
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 16 additional findings in Devin Review.

Open in Devin Review

Comment on lines 205 to 210
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;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 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;
   }
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 30 to 38
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
)
`;
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 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 sessions
  • needsCompaction() 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.
Open in Devin Review

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.
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 3 new potential issues.

View 16 additional findings in Devin Review.

Open in Devin Review

Comment on lines +205 to +209
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;
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot Mar 23, 2026

Choose a reason for hiding this comment

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

🟡 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 290 to 299
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})
`;
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot Mar 23, 2026

Choose a reason for hiding this comment

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

🟡 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.

Open in Devin Review

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
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 17 additional findings in Devin Review.

Open in Devin Review

Comment on lines 223 to 228
deleteMessage(messageId: string): void {
// Delete across all sessions — messages table is shared
for (const session of this._sessions.values()) {
session.deleteMessages([messageId]);
}
}
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot Mar 23, 2026

Choose a reason for hiding this comment

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

🟡 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.

Open in Devin Review

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
```
devin-ai-integration[bot]

This comment was marked as resolved.

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
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 30 additional findings in Devin Review.

Open in Devin Review

Comment on lines +235 to +238
role: "assistant",
parts: [{ type: "text", text: `[Previous conversation summary]\n${comp.summary}` }],
createdAt: new Date(),
} as UIMessage);
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot Mar 23, 2026

Choose a reason for hiding this comment

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

🟡 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.

Open in Devin Review

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() }
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 33 additional findings in Devin Review.

Open in Devin Review

Comment on lines +43 to +49
private compactFn = createCompactFunction({
summarize: (prompt) =>
generateText({ model: this.getAI(), prompt }).then((r) => r.text),
protectHead: 1,
minTailMessages: 2,
tailTokenBudget: 100,
});
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 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.
Open in Devin Review

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.
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 33 additional findings in Devin Review.

Open in Devin Review

Comment on lines +215 to +222
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: "" }));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 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')`;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +23 to +41
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"),
},
});
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 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}`)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@mattzcarey mattzcarey closed this Mar 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant