feat(experimental): Postgres session providers with Hyperdrive support#1297
feat(experimental): Postgres session providers with Hyperdrive support#1297mattzcarey wants to merge 4 commits intomainfrom
Conversation
|
- 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)
| 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); |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| "$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" |
There was a problem hiding this comment.
🟡 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.
| "$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>" | |
| } | |
| ], |
Was this helpful? React with 👍 or 👎 to provide feedback.
| function slugify(text: string): string { | ||
| return ( | ||
| text | ||
| .slice(0, 60) | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9]+/g, "-") | ||
| .replace(/^-|-$/g, "") || "entry" | ||
| ); | ||
| } |
There was a problem hiding this comment.
🔴 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
29f90e4 to
e6f3caa
Compare
| const blockDescriptions = writable.map( | ||
| (b) => `- "${b.label}": ${b.description ?? "no description"}` | ||
| ); |
There was a problem hiding this comment.
🟡 set_context tool description no longer distinguishes keyed blocks from regular writable blocks
In the set_context tool's blockDescriptions at context.ts:616-618, all writable blocks are now described identically as - "label": description. Previously, skill blocks were described as "skill collection (requires key and optional description)" and searchable blocks as "searchable (requires key)". Now the LLM has no indication from the block listing that some blocks require a title parameter while others don't. The title parameter description mentions which blocks it applies to, but the block listing itself gives no hint about the different write semantics. This increases the chance the LLM will omit title for keyed blocks, falling back to slugify(content) which produces poor auto-generated keys from content text.
Prompt for agents
In context.ts around line 616-618, the blockDescriptions array maps all writable blocks identically. Previously keyed blocks (skill and searchable) had distinct descriptions like 'skill collection (requires key and optional description)' and 'searchable (requires key)'. Consider restoring distinct descriptions for keyed blocks so the LLM understands the difference. For example, after the initial map, iterate over keyedBlocks and modify their entries to add a hint like '(keyed — provide title)' to their description string.
Was this helpful? React with 👍 or 👎 to provide feedback.
| * Wrap a pg Client to match the PostgresConnection interface. | ||
| * Converts `?` placeholders to `$1, $2, ...` for pg. | ||
| */ | ||
| function wrapPgClient(client: Client): PostgresConnection { |
There was a problem hiding this comment.
this feels like boilerplate. could we put this into the postgres session provider or how can we clean this up so people dont have to do this?
| private extractText(json: string): string { | ||
| const msg = this.parse(json); | ||
| if (!msg) return json; | ||
| return msg.parts | ||
| .filter((p) => p.type === "text" && p.text) | ||
| .map((p) => p.text) | ||
| .join("\n"); | ||
| } | ||
| } |
There was a problem hiding this comment.
why do we need this? are we only saving the text?
| description: | ||
| "Block label to write to. Must be one of: " + | ||
| writable.map((b) => `"${b.label}"`).join(", ") |
There was a problem hiding this comment.
I think I prefered this as an enum
| if (keyedBlocks.length > 0) { | ||
| properties.key = { | ||
| properties.title = { | ||
| type: "string" as const, | ||
| description: | ||
| "Entry key (required for keyed blocks: " + | ||
| keyedBlocks.map((b) => `"${b.label}"`).join(", ") + | ||
| ")" | ||
| }; | ||
| } | ||
|
|
||
| if (keyedBlocks.some((b) => b.isSkill)) { | ||
| properties.description = { | ||
| type: "string" as const, | ||
| description: "Short description for the skill entry" | ||
| "Short title for the entry. Used as a stable identifier — " + | ||
| "entries with the same title are updated, different titles create new entries. " + | ||
| "Applies to: " + | ||
| keyedBlocks.map((b) => `"${b.label}"`).join(", ") | ||
| }; | ||
| } |
There was a problem hiding this comment.
seems like this is for skills right?
so you can set_context(skill, title here, description here, content here)
need to make sure it is
`set_context("label of block", {
metadata_title: a good title,
metadata_description: "some metadata"
content: "this is the main thing here"
})
nothing needs metadata but its good to have it for longer stuff like skills (loadables). we should have this in the description of this tool.
| properties: { | ||
| label: { | ||
| type: "string" as const, | ||
| enum: skillLabels, | ||
| description: "Skill block label" | ||
| description: | ||
| "Skill block label. Must be one of: " + | ||
| skillLabels.map((l) => `"${l}"`).join(", ") | ||
| }, |
There was a problem hiding this comment.
again. i prefer enums
| "ONLY these blocks are searchable: " + | ||
| searchLabels.map((l) => `"${l}"`).join(", ") + | ||
| ".", | ||
| ". Other blocks (e.g. memory) cannot be searched.", |
There was a problem hiding this comment.
do not eg anything here.
| label: { | ||
| type: "string" as const, | ||
| enum: searchLabels, | ||
| description: "Searchable block label" | ||
| description: | ||
| "Searchable block label. Must be one of: " + | ||
| searchLabels.map((l) => `"${l}"`).join(", ") |
There was a problem hiding this comment.
again enum is more concise here
| * ``` | ||
| */ | ||
| static create(agent: SqlProvider): Session { | ||
| static create(storageOrAgent: SqlProvider | SessionProvider): Session { |
There was a problem hiding this comment.
rename this to provider. not storageOrAgent
| */ | ||
| private _reclaimLoadedSkill(label: string, key: string): void { | ||
| const history = this.storage.getHistory(); | ||
| private async _reclaimLoadedSkill(label: string, key: string): Promise<void> { |
There was a problem hiding this comment.
what does reclaim mean in this context?
| if (!provider) { | ||
| if (!this._agent) { | ||
| throw new Error( | ||
| `addContext("${label}") requires an explicit provider when Session uses a SessionProvider` |
There was a problem hiding this comment.
is the tool addContext or add_context
| const cacheIdx = this._cachedMessages.findIndex( | ||
| (m) => m.id === safe.id | ||
| ); | ||
| if (cacheIdx !== -1) this._cachedMessages[cacheIdx] = safe as UIMessage; |
There was a problem hiding this comment.
what do we need this for? I cant rememeber
Responses to @mattzcarey review on PR #1297. Covers all 13 comments: 1. Drop wrapPgClient boilerplate — Postgres providers now accept raw pg.Client directly via new providers/postgres-adapter.ts. The adapter normalises pg.Client-style (`query`) into the internal PostgresConnection shape (`execute`) and rewrites `?` placeholders to `$1, $2, …` so providers keep a driver-agnostic SQL dialect. 2. appendMessage parentId semantics — both PostgresSessionProvider and AgentSessionProvider were using `parentId ?? latestLeaf`, which collapsed undefined (auto-detect) with null (explicit root). Fixed to honour the documented contract: - undefined / omitted → auto-detect - explicit null → root with no parent SessionProvider JSDoc now documents this. New tests cover both cases for both providers. 3. extractText — renamed to extractSearchableText, added JSDoc explaining it feeds text_content for FTS while the full JSON stays in `content`. 4/6/8. Restored `enum` on label params across set_context, load_context, unload_context, search_context — schema-level enforcement instead of free-text description hints so smaller models can't hallucinate invalid labels. 5. set_context metadata shape — switched from flat `title` to nested `metadata: { title?, description? }`. Tool description now explains metadata is optional and useful for longer loadable entries (skills). setSkill() receives description ?? title so behaviour is preserved when only title is passed. 7. Dropped 'e.g. memory' example from search_context description — avoids seeding models with a non-existent block name. 9. Renamed Session.create(storageOrAgent) → Session.create(provider). 10. Async skill restore — _ensureReady() now kicks off restoration as a background _restorePromise; a new _ensureRestored() awaits it. Every async Session public method awaits _ensureRestored() before touching storage or skill state. unloadSkill / getLoadedSkillKeys are now async (internal callers only). Async SessionProviders (Postgres) now correctly rehydrate loaded-skill tracking after DO hibernation instead of silently dropping it. 11. Added JSDoc to _reclaimLoadedSkill explaining it reclaims context-window tokens by collapsing a load_context tool result to a short marker (kept the name per review feedback). 12. Clarified addContext JSDoc: it's a builder/host API, not an LLM tool; the LLM writes via set_context. 13. Added a comment on the Think._cachedMessages in-place patch explaining why it's not a full _syncMessages() call (in-flight streaming messages would be dropped — see commits 3f615a2, 6e76bd4). Example server.ts + docs/sessions.md updated for the new API and fixed the Devin-flagged premature client-caching bug (client is only assigned after connect() resolves). Tests: +4 in postgres-providers.test.ts (parentId null/undefined, raw pg.Client adapter for session/context/search providers), +2 in provider.test.ts (same parentId semantics for AgentSessionProvider). 161/161 session-related tests pass.
| const parent = | ||
| parentId !== undefined | ||
| ? parentId | ||
| : (((await this.latestLeafRow())?.id as string | undefined) ?? null); | ||
| const json = JSON.stringify(message); | ||
| const text = this.extractSearchableText(json); | ||
|
|
||
| await this.conn.execute( | ||
| `INSERT INTO assistant_messages (id, session_id, parent_id, role, content, text_content) | ||
| VALUES (?, ?, ?, ?, ?, ?) | ||
| ON CONFLICT (id) DO NOTHING`, | ||
| [message.id, this.sessionId, parent, message.role, json, text] | ||
| ); |
There was a problem hiding this comment.
🟡 PostgresSessionProvider.appendMessage lacks parent validation, allowing cross-session parent references
Unlike AgentSessionProvider.appendMessage (packages/agents/src/experimental/memory/session/providers/agent.ts:184-189), the PostgresSessionProvider does not validate that an explicitly supplied parentId belongs to the current session. If a caller passes a parentId that belongs to a different session (e.g., during SessionManager.fork or a bug in application code), the message will be inserted with a cross-session parent reference, silently corrupting the tree structure. The AgentSessionProvider falls back to parent = null when the parent doesn't belong to the session; the Postgres version should do the same.
| const parent = | |
| parentId !== undefined | |
| ? parentId | |
| : (((await this.latestLeafRow())?.id as string | undefined) ?? null); | |
| const json = JSON.stringify(message); | |
| const text = this.extractSearchableText(json); | |
| await this.conn.execute( | |
| `INSERT INTO assistant_messages (id, session_id, parent_id, role, content, text_content) | |
| VALUES (?, ?, ?, ?, ?, ?) | |
| ON CONFLICT (id) DO NOTHING`, | |
| [message.id, this.sessionId, parent, message.role, json, text] | |
| ); | |
| let parent = | |
| parentId !== undefined | |
| ? parentId | |
| : (((await this.latestLeafRow())?.id as string | undefined) ?? null); | |
| // Validate parentId belongs to this session | |
| if (parent) { | |
| const { rows: valid } = await this.conn.execute( | |
| "SELECT id FROM assistant_messages WHERE id = ? AND session_id = ?", | |
| [parent, this.sessionId] | |
| ); | |
| if (valid.length === 0) parent = null; | |
| } | |
| const json = JSON.stringify(message); | |
| const text = this.extractSearchableText(json); | |
| await this.conn.execute( | |
| `INSERT INTO assistant_messages (id, session_id, parent_id, role, content, text_content) | |
| VALUES (?, ?, ?, ?, ?, ?) | |
| ON CONFLICT (id) DO NOTHING`, | |
| [message.id, this.sessionId, parent, message.role, json, text] | |
| ); |
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (!block.content && !block.isSearchable) continue; | ||
| // Skip empty readonly blocks — writable/searchable always render | ||
| // so the LLM knows they exist and can write to them | ||
| if (!block.content && !block.writable && !block.isSearchable) continue; |
There was a problem hiding this comment.
🟡 captureSnapshot skips empty skill blocks that lack a .set method
The skip condition at line 498 if (!block.content && !block.writable && !block.isSearchable) continue does not check block.isSkill. A skill block backed by a read-only SkillProvider (one that implements load but not set) has writable=false, isSkill=true, isSearchable=false. When its content is empty (no skills indexed yet), the condition evaluates to true and the block is skipped from the system prompt entirely — even though the LLM should see the [loadable] block to know it can issue load_context calls. The condition should also preserve isSkill blocks, matching the intent expressed by the [loadable] tag on line 507.
| if (!block.content && !block.writable && !block.isSearchable) continue; | |
| if (!block.content && !block.writable && !block.isSearchable && !block.isSkill) continue; |
Was this helpful? React with 👍 or 👎 to provide feedback.
Bump @cloudflare/kumo from ^1.18.0 to ^1.19.0 and ai from ^6.0.159 to ^6.0.168 so the session-planetscale example matches every other package in the monorepo. Makes `npm run check` (sherif) pass without multiple-dependency-versions errors.
| private async getPgClient(): Promise<Client> { | ||
| if (this._pgClient) return this._pgClient; | ||
| const client = new Client({ | ||
| connectionString: this.env.HYPERDRIVE.connectionString | ||
| }); | ||
| await client.connect(); | ||
| this._pgClient = client; | ||
| return client; | ||
| } |
There was a problem hiding this comment.
🟡 Race condition in getPgClient creates duplicate connections that leak
In a Durable Object, concurrent requests can interleave at await points. If two requests call getPgClient() before _pgClient is assigned, both will see _pgClient as undefined, both create a new Client, both await client.connect(), and both assign to _pgClient. The first client is orphaned — its TCP connection is never closed. The same race exists in getSession() at line 40.
Fix: use a shared connect promise
Replace the if (this._pgClient) return guard with a promise-based pattern:
private _pgConnectPromise?: Promise<Client>;
private async getPgClient(): Promise<Client> {
if (!this._pgConnectPromise) {
this._pgConnectPromise = (async () => {
const client = new Client(...);
await client.connect();
return client;
})();
}
return this._pgConnectPromise;
}This ensures only one connection is ever created, and concurrent callers share the same promise.
Prompt for agents
The getPgClient() method has a classic check-then-act race condition. In a Durable Object, two concurrent requests can interleave at the `await client.connect()` line. Both see `_pgClient` as undefined, both create and connect a new Client, and the first one is leaked (never closed). The same pattern exists in getSession() at line 39-69.
Fix both methods by storing the in-flight promise rather than the resolved value. For getPgClient, store a `_pgConnectPromise: Promise<Client>` and assign it synchronously before the first await. For getSession, store `_sessionPromise: Promise<Session>` similarly. This ensures only one connection/session is ever created even under concurrent request interleaving.
Files: experimental/session-planetscale/src/server.ts (getPgClient at line 29, getSession at line 39)
Also affects: docs/sessions.md example at line 769-778 which shows the same pattern.
Was this helpful? React with 👍 or 👎 to provide feedback.
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 tsvectorPostgresContextProvider— writable context block storage (memory, cached prompt)PostgresSearchProvider— searchable knowledge base with tsvector + GIN indexFramework improvements (
packages/agents/src/experimental/memory/session/)Session.create()acceptsSessionProviderfor external storage (in addition toSqlProviderfor DO SQLite)set_contextAPI: removedkeyparam, auto-generates keys fromtitleor content slugfreezeSystemPrompt()returns cached,refreshSystemPrompt()force-reloads from providersclearMessages()callsrefreshSystemPrompt()to invalidate the cached promptappendToBlock()adds newline separator between entries[readonly],[searchable],[loadable],[not searchable]get()returns entry count only (no key listing)Example (
experimental/session-planetscale/)pgdriverwrapPgClienthelper converts?placeholders to$1, $2, ...for pg compatibilityTests (
packages/agents/src/tests/experimental/memory/session/postgres-providers.test.ts)convertToModelMessagescompatibility, prompt lifecycle (freeze/refresh/invalidation/concurrent)Docs (
docs/sessions.md)Migration SQL
Customers run this once — providers never create tables:
Test plan