Skip to content

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

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

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

Conversation

@mattzcarey
Copy link
Copy Markdown
Contributor

@mattzcarey mattzcarey commented Apr 13, 2026

Summary

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

Supersedes #1196.

What's new

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

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

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

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

Example (experimental/session-planetscale/)

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

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

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

Docs (docs/sessions.md)

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

Migration SQL

Customers run this once — providers never create tables:

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

Test plan

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

Open with Devin

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 13, 2026

⚠️ No Changeset found

Latest commit: 941fc10

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

devin-ai-integration[bot]

This comment was marked as resolved.

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: 941fc10

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

* ```
*/
static create(agent: SqlProvider): Session {
static create(storageOrAgent: SqlProvider | SessionProvider): Session {
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.

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> {
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 on lines +2426 to +2429
const cacheIdx = this._cachedMessages.findIndex(
(m) => m.id === safe.id
);
if (cacheIdx !== -1) this._cachedMessages[cacheIdx] = safe as UIMessage;
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 do we need this for? I cant rememeber

devin-ai-integration[bot]

This comment was marked as resolved.

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 on lines +143 to +155
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]
);
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.

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

Suggested change
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]
);
Open in Devin Review

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

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

Suggested change
if (!block.content && !block.writable && !block.isSearchable) continue;
if (!block.content && !block.writable && !block.isSearchable && !block.isSkill) continue;
Open in Devin Review

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.
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 on lines +29 to +37
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;
}
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.

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant