Skip to content

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

Open
mattzcarey wants to merge 29 commits intomainfrom
feat/postgres-session-provider
Open

feat(experimental): Postgres session providers with Hyperdrive support#1297
mattzcarey wants to merge 29 commits intomainfrom
feat/postgres-session-provider

Conversation

@mattzcarey
Copy link
Copy Markdown
Contributor

@mattzcarey mattzcarey commented Apr 13, 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.

Supersedes #1196.

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

Open with Devin

Matt and others added 4 commits April 13, 2026 16:18
- New experimental/session-planetscale example showing how to connect
  and wire up PlanetScaleSessionProvider + PlanetScaleContextProvider
- Fix type inconsistency: PlanetScaleSessionProvider now uses
  SessionMessage (matching the SessionProvider interface) instead of
  importing UIMessage directly from ai
- Rename PlanetScale → Postgres providers (PostgresSessionProvider, PostgresContextProvider, PostgresSearchProvider)
- Add PostgresSearchProvider with tsvector FTS for searchable knowledge blocks
- Harden context tools: remove enum constraints, validate in execute, return error strings instead of throwing
- Simplify set_context API: remove key param, auto-generate from title/content slug
- Search blocks show entry count only (no key listing)
- Append newline separator in appendToBlock
- Add ignoreIncompleteToolCalls to prevent orphaned tool call errors
- Add Hyperdrive example with Vite + React client
- Add 33 tests covering providers, round-trip, and convertToModelMessages compat
- Update docs with Postgres setup, migration SQL, and search docs
…prompt rendering

- Remove key param from set_context — auto-generate from title/content slug
- Add title param for stable keyed entries (skills/search)
- Harden all tools: remove enum constraints, validate in execute, return errors
- Fix prompt lifecycle: freezeSystemPrompt returns cached, refreshSystemPrompt reloads from providers
- Remove clearCachedPrompt — refreshSystemPrompt covers invalidation
- clearMessages calls refreshSystemPrompt to invalidate cached prompt
- Fix captureSnapshot: render empty writable blocks so LLM knows they exist
- Clean system prompt rendering: [readonly], [searchable], [loadable], [not searchable] tags
- Search provider get() returns count only, no key listing
- appendToBlock adds newline separator
- Simplify soul prompt in example
- Separate getSystemPrompt (cached) and refreshSystemPrompt (force reload) callables
- Add prompt lifecycle tests (freeze, refresh, invalidation, concurrent)
- Update docs: search provider, prompt lifecycle, generic Postgres setup
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 13, 2026

⚠️ No Changeset found

Latest commit: e416388

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

devin-ai-integration[bot]

This comment was marked as resolved.

- Await getMessage/updateMessage in SessionManager.upsert (manager.ts)
- Add session_id filter and depth guard to recursive CTEs in PostgresSessionProvider (postgres.ts)
- Use 'stored !== null' instead of 'stored' in freezeSystemPrompt to handle empty strings (context.ts)
- Guard against undefined _agent in addContext when using SessionProvider (session.ts)
devin-ai-integration[bot]

This comment was marked as resolved.

…and tests

Session methods became async to support PostgresSessionProvider, but
callers in think.ts, experimental examples, and test files were not
updated. This adds proper await/async handling throughout:

- think.ts: cache messages for sync getter, await all session calls
- manager.ts: make getHistory/clearMessages/deleteMessages/etc async
- multi-session.ts: await all session and manager calls in tests
- session.test.ts: fix UIMessage→SessionMessage, appendMessage return type
- session-search/server.ts: await getHistory/clearMessages
- session-multichat/server.ts: await getHistory
- client-tools.ts: await getBranches
- postgres.ts: fix parent type cast
devin-ai-integration[bot]

This comment was marked as resolved.

… updates

- Add _syncMessages() after _applyToolUpdateToMessages in Think
- Add .catch() to fire-and-forget _reclaimLoadedSkill callback
- Smart newline separator in appendToBlock (skip if content starts with \n)
- Fix [searchable] tag: show for all searchable blocks regardless of writability
- Update search/skills tests for removed key param and new tag format
devin-ai-integration[bot]

This comment was marked as resolved.

mattzcarey and others added 2 commits April 14, 2026 09:00
- Fix oxfmt formatting in context.ts and skills.test.ts
- Add @types/pg dev dependency for typecheck
- Rename getConnection → getPgConnection to avoid Agent base class collision
- Fix Text component className prop in client.tsx
- Await clearMessages() in SessionManager.delete()
devin-ai-integration[bot]

This comment was marked as resolved.

- Await manager.delete() in multichat example and multi-session test
- Extract text parts from JSON in PostgresSessionProvider.searchMessages
  instead of returning raw JSON content
…return types

- Add Session.create(SessionProvider) tests to session.test.ts (runs under workers pool)
- Fix appendMessage mock return type (block syntax to return void, not number)
- Add await to getHistory in minimal create test
- Remove unused imports from postgres-providers.test.ts
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 2 new potential issues.

View 16 additional findings in Devin Review.

Open in Devin Review

return `Error: key is required for searchable block "${label}"`;
await this.setSearchEntry(label, key, content);
if (block.isSkill || block.isSearchable) {
const key = slugify(title ?? content);
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.

🟡 slugify truncation causes silent key collisions when title is omitted for keyed blocks

When the LLM calls set_context for a skill or searchable block without providing a title, the key is generated via slugify(content) which truncates at 60 characters. Two different contents that share the same first 60 characters (after lowercasing and stripping non-alphanumeric chars) will produce identical keys, causing the second entry to silently overwrite the first.

For example, "The deployment process for production requires approval from security team" and "The deployment process for production requires approval from management" would both slugify to the same key. The old code required an explicit key parameter, avoiding this collision risk entirely.

Suggested change
const key = slugify(title ?? content);
const key = title ? slugify(title) : `${slugify(content)}-${Array.from(new TextEncoder().encode(content)).reduce((h, b) => (((h << 5) - h) + b) | 0, 0).toString(36).replace('-', 'n')}`;
Open in Devin Review

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

- Add text_content column to assistant_messages migration
- Generate tsvector from text_content instead of raw JSON content
- Populate text_content with extracted text parts on append/update
- Search results return text_content directly
- Fix mock handleUpdate for new column layout
- Align ai dependency version with main (^6.0.158)
- Fix oxfmt formatting in react.tsx
- Fix implicit any in resumable-stream-chat onData callback
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

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 3 new potential issues.

View 21 additional findings in Devin Review.

Open in Devin Review

Comment on lines +2 to +19
"$schema": "../../node_modules/wrangler/config-schema.json",
"account_id": "543fbdef1eeaed8a02c251c8c4d9510b",
"name": "agents-session-planetscale-example",
"main": "src/server.ts",
"compatibility_date": "2026-01-28",
"compatibility_flags": ["nodejs_compat"],
"ai": {
"binding": "AI"
},
"assets": {
"directory": "./public",
"not_found_handling": "single-page-application",
"run_worker_first": ["/agents/*"]
},
"hyperdrive": [
{
"binding": "HYPERDRIVE",
"id": "e9c4a010628841f2a23f30d7fdceb63d"
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.

🟡 Hardcoded account_id and Hyperdrive ID in example wrangler.jsonc

The experimental/session-planetscale/wrangler.jsonc hardcodes account_id and a Hyperdrive id. The repository's AGENTS.md mandates "Never hardcode secrets or API keys." No other example in the repo includes account_id in its wrangler config. The Hyperdrive ID (e9c4a010628841f2a23f30d7fdceb63d) identifies a specific deployed resource tied to an individual account, and will fail for any other contributor or deployment.

Suggested change
"$schema": "../../node_modules/wrangler/config-schema.json",
"account_id": "543fbdef1eeaed8a02c251c8c4d9510b",
"name": "agents-session-planetscale-example",
"main": "src/server.ts",
"compatibility_date": "2026-01-28",
"compatibility_flags": ["nodejs_compat"],
"ai": {
"binding": "AI"
},
"assets": {
"directory": "./public",
"not_found_handling": "single-page-application",
"run_worker_first": ["/agents/*"]
},
"hyperdrive": [
{
"binding": "HYPERDRIVE",
"id": "e9c4a010628841f2a23f30d7fdceb63d"
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "agents-session-planetscale-example",
"main": "src/server.ts",
"compatibility_date": "2026-01-28",
"compatibility_flags": ["nodejs_compat"],
"ai": {
"binding": "AI"
},
"assets": {
"directory": "./public",
"not_found_handling": "single-page-application",
"run_worker_first": ["/agents/*"]
},
"hyperdrive": [
{
"binding": "HYPERDRIVE",
"id": "<your-hyperdrive-id>"
}
],
Open in Devin Review

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

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 new potential issue.

View 22 additional findings in Devin Review.

Open in Devin Review

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.

🔴 DO-backed test agent passes removed key parameter to set_context tool

The set_context tool was refactored to use title instead of key for keyed blocks (skill/search providers). The tool's execute function destructures { label, content, title, action } (packages/agents/src/experimental/memory/session/context.ts:657-667). However, the TestSearchAgent integration test agent still passes key as a parameter (e.g., key: "meeting-notes") which is silently ignored. The key is auto-generated via slugify(content) instead, producing keys like the-deployment-is-scheduled-for-friday-with-budget-concerns. The test then asserts r1.includes("meeting-notes") which will fail since that key was never stored. The testUpdateReplacesEntry method has the same issue — it passes key: "doc" twice with different content, but the slugified keys will differ, so the second write creates a new entry instead of updating the first.

(Refers to lines 158-167)

Prompt for agents
The set_context tool was refactored from using a `key` parameter to `title`. The TestSearchAgent class in packages/agents/src/tests/agents/session.ts needs to be updated:

1. testIndexAndSearch (line 158-167): Change `key: "meeting-notes"` to `title: "meeting-notes"` and `key: "design-doc"` to `title: "design-doc"`. The assertions checking for these keys in search results should also use the slugified versions of the titles (which happen to be the same: "meeting-notes" and "design-doc").

2. testUpdateReplacesEntry (line 242-250): Change both `key: "doc"` to `title: "doc"`. Since slugify("doc") = "doc", the upsert behavior should work correctly with this fix.

3. testInitLifecycle (line 219): The assertion `prompt.includes("search_context")` needs to be changed to `prompt.includes("[searchable]")` since the prompt format now uses tag-style markers instead of tool name hints.
Open in Devin Review

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

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 3 new potential issues.

View 25 additional findings in Devin Review.

Open in Devin Review

Comment on lines +24 to +32
function slugify(text: string): string {
return (
text
.slice(0, 60)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "entry"
);
}
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.

🔴 slugify fallback key "entry" causes silent data overwrites for non-Latin content

The new slugify function strips all non [a-z0-9] characters, then falls back to "entry" if the result is empty. When the LLM doesn't provide a title (it's optional), slugify(content) is used as the key. For non-Latin content (Chinese, Japanese, Arabic, emoji-only, etc.), slugify produces "entry" for every input, causing all entries to silently overwrite each other.

Example of the collision

For a knowledge base with entries:

  • set_context({ label: "knowledge", content: "用户喜欢咖啡" }) → key = "entry"
  • set_context({ label: "knowledge", content: "用户的名字是小明" }) → key = "entry" (overwrites first!)

The second write silently replaces the first because both map to key "entry".

Prompt for agents
The slugify function in context.ts:24-32 strips all non-ASCII-alphanumeric characters and falls back to "entry" when nothing remains. This causes silent data loss for non-Latin content (Chinese, Japanese, Arabic, emoji, etc.) since all such content maps to the same key "entry", overwriting each other.

The function is used in the set_context tool at context.ts:673 where `slugify(title ?? content)` generates the storage key for skill/search blocks.

Possible approaches:
1. Use a hash (e.g., first 8 chars of a SHA-256 hex digest) of the full text as a fallback instead of the static "entry" string.
2. Allow Unicode letters in the slug (e.g., use a Unicode-aware regex like /[^\p{L}\p{N}]+/gu).
3. Generate a random UUID as the fallback key when the slug is empty.

Any approach must ensure that the same input consistently produces the same key (for upsert semantics), so option 1 (hash-based) is likely the best fit.
Open in Devin Review

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

Comment on lines +125 to +126
const parent =
parentId ?? ((await this.latestLeafRow())?.id as string) ?? null;
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.

🔴 appendMessage with explicit parentId: null incorrectly auto-detects parent instead of creating a root message

In PostgresSessionProvider.appendMessage, parentId uses nullish coalescing (??) which treats both null and undefined the same way. When called with explicit parentId: null (meaning "create a root message with no parent"), the code falls through to latestLeafRow() and attaches the message as a child of the latest leaf instead.

Code path

At postgres.ts:125-126:

const parent =
  parentId ?? ((await this.latestLeafRow())?.id as string) ?? null;

If parentId is null, null ?? latestLeaf evaluates to latestLeaf, not null. The SessionProvider interface at provider.ts:57-60 declares parentId?: string | null, where null should mean "no parent" and undefined/omitted should mean "auto-detect".

This breaks the branching contract — callers who explicitly pass null to create a root message get an unexpected parent chain instead. The AgentSessionProvider at providers/agent.ts likely has the same distinction via its SQL logic.

Suggested change
const parent =
parentId ?? ((await this.latestLeafRow())?.id as string) ?? null;
const parent =
parentId !== undefined
? parentId
: (((await this.latestLeafRow())?.id as string) ?? null);
Open in Devin Review

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

Comment on lines +215 to +216
// If the provider is async, history is a Promise — skip restore for async providers
if (history instanceof Promise) return;
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.

🟡 Skill restoration silently skipped for async providers, losing loaded-skill tracking after hibernation

_restoreLoadedSkills() at packages/agents/src/experimental/memory/session/session.ts:214-216 checks if (history instanceof Promise) return and silently skips skill restoration for all async SessionProvider implementations (including the new PostgresSessionProvider). After DO hibernation/eviction, skills that were loaded via load_context are forgotten — the _loadedSkills set is empty. This means unload_context reports "not currently loaded" for skills that are actually loaded in the conversation, and the unload_context tool description shows "No skills currently loaded" even when skills are present in history.

Prompt for agents
In session.ts _restoreLoadedSkills(), the method skips entirely when the provider is async (returns a Promise from getHistory). This causes loaded skills to be silently lost after hibernation for Postgres-backed sessions.

Consider making _restoreLoadedSkills async and calling it with await in _ensureReady. Since _ensureReady is called at the start of every Session method (which are all now async), making it async should be safe. Alternatively, defer the restore to the first async method call (e.g. inside getHistory or tools) so it runs before the data is needed.

The key issue is in _ensureReady (line 157) which is synchronous. Either make _ensureReady async and await skill restoration, or lazily restore skills on first async use.
Open in Devin Review

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

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