Skip to content

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

Merged
threepointone merged 8 commits into
mainfrom
feat/postgres-session-provider
May 19, 2026
Merged

feat(experimental): Postgres session providers with Hyperdrive support#1297
threepointone merged 8 commits into
mainfrom
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

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 13, 2026

🦋 Changeset detected

Latest commit: 89c8801

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
agents Minor
@cloudflare/think Minor

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

devin-ai-integration[bot]

This comment was marked as resolved.

mattzcarey added a commit that referenced this pull request Apr 13, 2026
- 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.

devin-ai-integration[bot]

This comment was marked as resolved.

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

@devin-ai-integration devin-ai-integration Bot Apr 14, 2026

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.

Open in Devin Review

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

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 thread packages/think/src/think.ts
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.

Comment thread packages/agents/src/experimental/memory/session/session.ts
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 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 thread packages/agents/src/experimental/memory/session/providers/postgres.ts Outdated
Comment thread packages/agents/src/experimental/memory/session/session.ts Outdated
devin-ai-integration[bot]

This comment was marked as resolved.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 14, 2026

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@1297

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@1297

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@1297

hono-agents

npm i https://pkg.pr.new/hono-agents@1297

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@1297

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@1297

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@1297

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@1297

commit: 89c8801

devin-ai-integration[bot]

This comment was marked as resolved.

@mattzcarey mattzcarey force-pushed the feat/postgres-session-provider branch from 29f90e4 to e6f3caa Compare April 15, 2026 16:46
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 28 additional findings in Devin Review.

Open in Devin Review

Comment on lines 616 to 618
const blockDescriptions = writable.map(
(b) => `- "${b.label}": ${b.description ?? "no description"}`
);
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.

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

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 {
Copy link
Copy Markdown
Contributor Author

@mattzcarey mattzcarey Apr 22, 2026

Choose a reason for hiding this comment

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

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?

Comment on lines +292 to +300
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");
}
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

why do we need this? are we only saving the text?

Comment on lines +624 to +626
description:
"Block label to write to. Must be one of: " +
writable.map((b) => `"${b.label}"`).join(", ")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think I prefered this as an enum

Comment on lines 639 to 648
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(", ")
};
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines 710 to 716
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(", ")
},
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

again. i prefer enums

"ONLY these blocks are searchable: " +
searchLabels.map((l) => `"${l}"`).join(", ") +
".",
". Other blocks (e.g. memory) cannot be searched.",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

do not eg anything here.

Comment on lines +788 to +792
label: {
type: "string" as const,
enum: searchLabels,
description: "Searchable block label"
description:
"Searchable block label. Must be one of: " +
searchLabels.map((l) => `"${l}"`).join(", ")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

again enum is more concise here

Comment thread packages/agents/src/experimental/memory/session/session.ts Outdated
*/
private _reclaimLoadedSkill(label: string, key: string): void {
const history = this.storage.getHistory();
private async _reclaimLoadedSkill(label: string, key: string): Promise<void> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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`
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

is the tool addContext or add_context

Comment thread packages/think/src/think.ts Outdated
devin-ai-integration[bot]

This comment was marked as resolved.

mattzcarey added a commit that referenced this pull request Apr 22, 2026
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.
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 31 additional findings in Devin Review.

Open in Devin Review

Comment thread packages/agents/src/experimental/memory/session/providers/postgres.ts Outdated
Comment thread packages/agents/src/experimental/memory/session/context.ts Outdated
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 31 additional findings in Devin Review.

Open in Devin Review

Comment thread experimental/session-planetscale/src/server.ts Outdated
Copy link
Copy Markdown
Contributor

@whoiskatrin whoiskatrin left a comment

Choose a reason for hiding this comment

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

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.

Comment thread docs/sessions.md Outdated
Comment thread packages/agents/src/experimental/memory/session/providers/postgres.ts Outdated
Comment thread packages/agents/src/experimental/memory/session/providers/postgres.ts Outdated
@whoiskatrin
Copy link
Copy Markdown
Contributor

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.

@whoiskatrin
Copy link
Copy Markdown
Contributor

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.

Matt and others added 4 commits May 18, 2026 12:52
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>
@threepointone
Copy link
Copy Markdown
Contributor

looking into this PR today

threepointone and others added 3 commits May 18, 2026 13:50
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.
@threepointone threepointone force-pushed the feat/postgres-session-provider branch from 941fc10 to 755d578 Compare May 18, 2026 15:12
whoiskatrin

This comment was marked as duplicate.

Comment thread packages/think/src/think.ts Outdated
Comment thread packages/agents/src/experimental/memory/session/providers/postgres.ts Outdated
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.
@threepointone threepointone merged commit d151e6d into main May 19, 2026
4 checks passed
@threepointone threepointone deleted the feat/postgres-session-provider branch May 19, 2026 08:24
@github-actions github-actions Bot mentioned this pull request May 18, 2026
cjol pushed a commit that referenced this pull request May 20, 2026
#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>
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.

3 participants