Skip to content

feat(experimental): Postgres session providers with Hyperdrive support#1196

Closed
mattzcarey wants to merge 2 commits intomainfrom
feat/planetscale-session-provider
Closed

feat(experimental): Postgres session providers with Hyperdrive support#1196
mattzcarey wants to merge 2 commits intomainfrom
feat/planetscale-session-provider

Conversation

@mattzcarey
Copy link
Copy Markdown
Contributor

@mattzcarey mattzcarey commented Mar 25, 2026

Summary

Adds Postgres-backed session providers for storing conversation history, context blocks, and searchable knowledge in an external database via Hyperdrive. This enables cross-DO queries, analytics, and shared state without relying on DO SQLite.

What's new

Providers (packages/agents/src/experimental/memory/session/providers/)

  • PostgresSessionProvider — tree-structured messages, compaction overlays, message FTS via tsvector
  • PostgresContextProvider — writable context block storage (memory, cached prompt)
  • PostgresSearchProvider — searchable knowledge base with tsvector + GIN index

Framework improvements (packages/agents/src/experimental/memory/session/)

  • Session.create() accepts SessionProvider for external storage (in addition to SqlProvider for DO SQLite)
  • Hardened context tools: removed enum constraints on label params, validate inside execute, always return error strings instead of throwing (prevents orphaned tool calls with smaller LLMs)
  • Simplified set_context API: removed key param, auto-generates keys from title or content slug
  • Fixed prompt lifecycle: freezeSystemPrompt() returns cached, refreshSystemPrompt() force-reloads from providers
  • clearMessages() calls refreshSystemPrompt() to invalidate the cached prompt
  • appendToBlock() adds newline separator between entries
  • Empty writable blocks render in system prompt so the LLM knows they exist
  • Clean block tags: [readonly], [searchable], [loadable], [not searchable]
  • Search provider get() returns entry count only (no key listing)

Example (experimental/session-planetscale/)

  • Full Vite + React chat app with Hyperdrive + pg driver
  • System prompt toggle, FULLTEXT search bar, connection indicator, theme toggle
  • wrapPgClient helper converts ? placeholders to $1, $2, ... for pg compatibility

Tests (packages/agents/src/tests/experimental/memory/session/postgres-providers.test.ts)

  • 37 tests covering: provider CRUD, message round-trip, dynamic-tool part serialization, convertToModelMessages compatibility, prompt lifecycle (freeze/refresh/invalidation/concurrent)

Docs (docs/sessions.md)

  • Postgres setup guide: migration SQL, Hyperdrive config, wire-up code
  • System prompt lifecycle docs
  • Search provider docs (message search + knowledge search)

Migration SQL

Customers run this once — providers never create tables:

CREATE TABLE assistant_messages (
  id TEXT PRIMARY KEY, session_id TEXT NOT NULL DEFAULT '', parent_id TEXT,
  role TEXT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(),
  content_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', content)) STORED
);
CREATE TABLE assistant_compactions (
  id TEXT PRIMARY KEY, session_id TEXT NOT NULL DEFAULT '',
  summary TEXT NOT NULL, from_message_id TEXT NOT NULL, to_message_id TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE cf_agents_context_blocks (
  label TEXT PRIMARY KEY, content TEXT NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE cf_agents_search_entries (
  label TEXT NOT NULL, key TEXT NOT NULL, content TEXT NOT NULL,
  content_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
  created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(),
  PRIMARY KEY (label, key)
);

Test plan

  • 37 unit tests passing (providers, round-trip, convertToModelMessages, prompt lifecycle)
  • Deploy example and test chat flow end-to-end
  • Verify search_context returns ranked results from knowledge base
  • Verify clearMessages invalidates cached prompt
  • Verify empty memory block renders in system prompt

mattzcarey and others added 2 commits March 24, 2026 18:48
Core session primitives for the agents package:

- Session class with tree-structured messages, compaction overlays, context blocks, FTS5 search
- Chainable builder: Session.create(agent).withContext(...).withCachedPrompt()
- AgentSessionProvider: SQLite-backed with session_id scoping, content column (Think-compatible)
- AgentContextProvider: key-value block storage
- ContextProvider interface for custom backends (R2, KV, etc.)
- Compaction utilities with head/tail protection
- Iterative compaction: newer overlays supersede older ones at same fromId
- session-memory example with builder API
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 25, 2026

⚠️ No Changeset found

Latest commit: a4435ab

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
Copy Markdown
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 potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment on lines +243 to +257
async searchMessages(query: string, limit = 20): Promise<SearchResult[]> {
await this.ensureTable();
const { rows } = await this.conn.execute(
`SELECT id, role, content FROM assistant_messages
WHERE session_id = ? AND MATCH(content) AGAINST(? IN NATURAL LANGUAGE MODE)
LIMIT ?`,
[this.sessionId, query, limit]
);
return rows.map((r) => ({
id: r.id as string,
role: r.role as string,
content: r.content as string,
createdAt: ""
}));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 PlanetScale searchMessages returns raw JSON blobs instead of extracted text content

The PlanetScaleSessionProvider.searchMessages() selects content from the assistant_messages table, which stores the full JSON.stringify(message) blob (set at planetscale.ts:166-170). This means both the FULLTEXT search and the returned SearchResult.content operate on raw JSON like {"id":"m1","role":"user","parts":[{"type":"text","text":"hello"}]} rather than the actual message text. In contrast, the reference AgentSessionProvider maintains a separate FTS5 table with extracted text parts (packages/agents/src/experimental/memory/session/providers/agent.ts:285-297) and queries that table for search (agent.ts:261-264). This causes two problems: (1) FULLTEXT search matches against JSON structure/keys (e.g., searching for "type" or "parts" would match every message), producing false positives, and (2) SearchResult.content returns an unparseable JSON string where callers expect readable text.

Prompt for agents
In packages/agents/src/experimental/memory/session/providers/planetscale.ts, the searchMessages method (lines 243-257) queries the assistant_messages table directly, which stores full JSON-serialized UIMessage objects. This causes both incorrect search matching (FULLTEXT on JSON blobs) and incorrect output (SearchResult.content is a JSON string instead of readable text).

To fix this properly, mirror the approach used by AgentSessionProvider (packages/agents/src/experimental/memory/session/providers/agent.ts:285-297):

1. Parse the JSON content in the search results to extract actual text parts, similar to how AgentSessionProvider.indexFTS extracts text:
   message.parts.filter(p => p.type === 'text').map(p => p.text).join(' ')

2. For the SearchResult.content field, parse each row's content JSON and extract the text parts instead of returning raw JSON.

3. Ideally, consider creating a separate search table (like the agent provider's assistant_fts) that stores extracted text for proper FULLTEXT indexing. Without this, FULLTEXT search will still match against JSON structure, but at minimum the output content should be parsed text.

At minimum, change lines 251-256 to parse the JSON content and extract text parts before returning.
Open in Devin Review

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

@mattzcarey mattzcarey force-pushed the feat/session-api-core branch 2 times, most recently from f8849ca to 616da1c Compare March 26, 2026 20:10
Base automatically changed from feat/session-api-core to main March 26, 2026 21:10
@mattzcarey mattzcarey changed the title feat(experimental): PlanetScale SessionProvider + async interface feat(experimental): Postgres session providers with Hyperdrive support Apr 13, 2026
@mattzcarey
Copy link
Copy Markdown
Contributor Author

Superseded by #1297 (clean branch, rebased on main)

@mattzcarey mattzcarey closed this Apr 13, 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