feat(experimental): Postgres session providers with Hyperdrive support#1297
Conversation
🦋 Changeset detectedLatest commit: 89c8801 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
- 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
| */ | ||
| 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
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.
whoiskatrin
left a comment
There was a problem hiding this comment.
Nice, I like this direction. The provider split feels right to me and I’m glad pg is only in the example, not in the package itself.
I do think there are a few things to fix before this lands though.
|
Looks like this needs a changeset. This adds new public exports and changes some Think methods to async, so I think we should have release notes for it. |
|
One more thing, docs/sessions.md has a section that still reads like Session is mostly sync. I think those examples should use await, and the note saying updateMessage deleteMessages and clearMessages are sync should be removed. Maybe just document the rule as, Session methods are async. SQLite is fast, external storage may do IO. |
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.
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.
Align the new PlanetScale example with the rebased workspace dependencies and update Think async-session call sites/tests so the branch stays typecheck- and lint-clean on current main. Co-authored-by: Cursor <cursoragent@cursor.com>
|
looking into this PR today |
Tighten the Postgres session provider for shared database usage by scoping message id conflicts to (session_id, id) and validating explicit parent ids against the current session before storing them. This keeps caller-provided message ids safe across sessions and preserves the SQLite provider's fallback-to-root behavior for invalid parents. Make generated keys for keyed context writes deterministic but collision-resistant when the model omits metadata.title. Title-based writes remain stable update keys, while content-derived keys now include a short hash so long shared prefixes and non-Latin content do not silently overwrite unrelated skill or search entries. Clean up the new PlanetScale example and docs for merge readiness: remove committed Cloudflare account/resource IDs, document the required Hyperdrive placeholder, use raw pg.Client in examples, initialize the client/session from onStart instead of request-created promises, update Session docs for the async API, document the Postgres composite message primary key, and add the relevant changeset for the new public providers and async session surface. Tests cover cross-session duplicate message ids, foreign-session parent fallback, and generated key collision cases. Co-authored-by: Cursor <cursoragent@cursor.com>
Teach Session to notify internal listeners after message mutations so cache-owning framework code can mirror durable storage changes without widening the public API. Think now registers that hook during startup, treats `messages` as its live cached view, and routes writes through history helpers that sanitize and enforce row-size limits before delegating to Session. This avoids full storage rereads during active streaming turns, while still refreshing at safe boundaries for duplicate appends, branch writes, deletes, clears, and compaction overlays. It also makes direct `this.session.appendMessage()` calls from advanced Think subclasses update the live cache through the same observer path. Add regression coverage for duplicate message IDs, compaction-triggered refreshes, direct Session appends, subclass append helpers, `getMessages()` copy semantics, and host-injected messages. Update the Session docs and PlanetScale example README for async APIs, Postgres-backed search/storage wording, Durable Object persistence semantics, and mark the changeset as a minor bump because the async Session API is breaking for 0.x consumers. Co-authored-by: Cursor <cursoragent@cursor.com>
Treat empty skill blocks as renderable in ContextBlocks so the LLM can discover loadable skill collections (add !block.isSkill to the skip logic). Include a human-readable kind for keyed/writable blocks in the set_context description ("skill collection, keyed entries", "searchable, keyed entries", or "writable"). Add unit tests and small test providers (EmptySkillProvider, WritableSkillProvider, WritableSearchProvider) to verify empty skill block rendering and that tools().set_context lists keyed block kinds and metadata fields.
941fc10 to
755d578
Compare
Normalize created_at values returned from Postgres to ISO strings (handle Date objects and other types) in PostgresSessionProvider and add a unit test for this behavior. In Think, replace an upsert on session update events with a patch-only _patchCachedMessage implementation so updateMessage no longer inserts messages that are missing from the live cache; add a test helper and a test to ensure missing messages are not appended. These changes prevent Date objects from leaking into API fields and stop update events from creating unexpected cached entries.
#1297) * feat(experimental): PlanetScale SessionProvider + async interface * fix(session): address PR #1297 review feedback 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. * chore(session-planetscale): align kumo + ai dep versions with workspace 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. * fix(session): resolve postgres provider rebase fallout Align the new PlanetScale example with the rebased workspace dependencies and update Think async-session call sites/tests so the branch stays typecheck- and lint-clean on current main. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(session): harden postgres provider follow-ups Tighten the Postgres session provider for shared database usage by scoping message id conflicts to (session_id, id) and validating explicit parent ids against the current session before storing them. This keeps caller-provided message ids safe across sessions and preserves the SQLite provider's fallback-to-root behavior for invalid parents. Make generated keys for keyed context writes deterministic but collision-resistant when the model omits metadata.title. Title-based writes remain stable update keys, while content-derived keys now include a short hash so long shared prefixes and non-Latin content do not silently overwrite unrelated skill or search entries. Clean up the new PlanetScale example and docs for merge readiness: remove committed Cloudflare account/resource IDs, document the required Hyperdrive placeholder, use raw pg.Client in examples, initialize the client/session from onStart instead of request-created promises, update Session docs for the async API, document the Postgres composite message primary key, and add the relevant changeset for the new public providers and async session surface. Tests cover cross-session duplicate message ids, foreign-session parent fallback, and generated key collision cases. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(think): keep session message cache coherent Teach Session to notify internal listeners after message mutations so cache-owning framework code can mirror durable storage changes without widening the public API. Think now registers that hook during startup, treats `messages` as its live cached view, and routes writes through history helpers that sanitize and enforce row-size limits before delegating to Session. This avoids full storage rereads during active streaming turns, while still refreshing at safe boundaries for duplicate appends, branch writes, deletes, clears, and compaction overlays. It also makes direct `this.session.appendMessage()` calls from advanced Think subclasses update the live cache through the same observer path. Add regression coverage for duplicate message IDs, compaction-triggered refreshes, direct Session appends, subclass append helpers, `getMessages()` copy semantics, and host-injected messages. Update the Session docs and PlanetScale example README for async APIs, Postgres-backed search/storage wording, Durable Object persistence semantics, and mark the changeset as a minor bump because the async Session API is breaking for 0.x consumers. Co-authored-by: Cursor <cursoragent@cursor.com> * Render skill blocks and label keyed block kinds Treat empty skill blocks as renderable in ContextBlocks so the LLM can discover loadable skill collections (add !block.isSkill to the skip logic). Include a human-readable kind for keyed/writable blocks in the set_context description ("skill collection, keyed entries", "searchable, keyed entries", or "writable"). Add unit tests and small test providers (EmptySkillProvider, WritableSkillProvider, WritableSearchProvider) to verify empty skill block rendering and that tools().set_context lists keyed block kinds and metadata fields. * Normalize Postgres timestamps and patch cache Normalize created_at values returned from Postgres to ISO strings (handle Date objects and other types) in PostgresSessionProvider and add a unit test for this behavior. In Think, replace an upsert on session update events with a patch-only _patchCachedMessage implementation so updateMessage no longer inserts messages that are missing from the live cache; add a test helper and a test to ensure missing messages are not appended. These changes prevent Date objects from leaking into API fields and stop update events from creating unexpected cached entries. --------- Co-authored-by: Matt <matt@test.com> Co-authored-by: Sunil Pai <spai@cloudflare.com> Co-authored-by: Cursor <cursoragent@cursor.com>
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