Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
10ee381
feat(experimental): PlanetScale SessionProvider + async interface
Mar 25, 2026
8e7405a
add PlanetScale example + fix UIMessage→SessionMessage in provider
mattzcarey Apr 10, 2026
1127f46
feat(experimental): Postgres session provider with Hyperdrive support
mattzcarey Apr 13, 2026
505f2ff
refactor: harden session tools, fix prompt lifecycle, improve system …
mattzcarey Apr 13, 2026
9a265a8
fix: align dependency versions and fix typecheck errors
mattzcarey Apr 13, 2026
c4e65a8
format
mattzcarey Apr 13, 2026
8d2e419
fix: address PR #1297 review feedback
mattzcarey Apr 13, 2026
9a0e19a
fix: add await to all async Session/SessionManager calls in think.ts …
mattzcarey Apr 13, 2026
a3c98bb
fix: address review feedback — CTE session filter, async safety, test…
mattzcarey Apr 13, 2026
ba4821a
Merge branch 'main' into feat/postgres-session-provider
mattzcarey Apr 14, 2026
cab8e7f
fix: formatting, typecheck errors, and SessionManager.delete await
mattzcarey Apr 14, 2026
4b728c0
fix: await manager.delete(), extract text in searchMessages
mattzcarey Apr 14, 2026
bc40a53
fix: add SessionProvider tests to session.test.ts, fix appendMessage …
mattzcarey Apr 14, 2026
bb7fcf3
fix: index extracted text instead of raw JSON in message FTS
mattzcarey Apr 14, 2026
7f3aaf6
fix: formatting and typecheck issues from merge
mattzcarey Apr 14, 2026
cb667e9
fix: trailing comma in react.tsx formatting
mattzcarey Apr 14, 2026
297766c
fix: align ai dep with main (^6.0.159)
mattzcarey Apr 14, 2026
5f56bf3
fix: rename [not searchable] tag to [writable]
mattzcarey Apr 14, 2026
e113f9c
fix: format think CHANGELOG.md from main
mattzcarey Apr 14, 2026
5b50885
Merge remote-tracking branch 'origin/main' into feat/postgres-session…
mattzcarey Apr 14, 2026
554dda1
fix: format think CHANGELOG.md
mattzcarey Apr 14, 2026
6da600c
fix: match agent provider compaction format — use raw summary without…
mattzcarey Apr 14, 2026
6e76bd4
fix: update cached messages in-place in _applyToolUpdateToMessages in…
mattzcarey Apr 14, 2026
3f615a2
fix: revert _syncMessages in _applyToolUpdateToMessages — cached mess…
mattzcarey Apr 14, 2026
a31bf5a
fix: sync cached messages after appendMessage in Think.chat()
mattzcarey Apr 14, 2026
4fc07f0
fix: update _cachedMessages in-place in _applyToolUpdateToMessages
mattzcarey Apr 14, 2026
3b44768
fix: read fresh history in _applyToolUpdateToMessages to handle in-fl…
mattzcarey Apr 14, 2026
87a2561
fix: make getMessages async — reads fresh from session storage
mattzcarey Apr 14, 2026
e416388
fix: sync cache in persistTestMessage; update test for empty writable…
mattzcarey Apr 14, 2026
3c8f820
fix: update test for empty writable block rendering
mattzcarey Apr 14, 2026
1bd6fc9
fix: update search test to use title instead of removed key param
mattzcarey Apr 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 196 additions & 1 deletion docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,197 @@ const myStorage: SessionProvider = {

---

## Postgres (External Database)

The built-in providers use Durable Object SQLite. If you need session data in an external Postgres database — for cross-DO queries, analytics, or shared state — use `PostgresSessionProvider` and `PostgresContextProvider`.

These work with any Postgres-compatible database (Neon, Supabase, PlanetScale, etc.) via [Cloudflare Hyperdrive](https://developers.cloudflare.com/hyperdrive/) for connection pooling.

### Setup

#### 1. Create a Postgres database

Use any Postgres provider and copy the connection string.

#### 2. Create a Hyperdrive config

```bash
npx wrangler hyperdrive create my-session-db \
--connection-string="postgresql://user:password@host:port/dbname"
```

Copy the returned Hyperdrive ID.

#### 3. Create the tables

The Postgres user typically won't have `CREATE TABLE` permissions. Run this once in your database console:

```sql
CREATE TABLE IF NOT EXISTS assistant_messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL DEFAULT '',
parent_id TEXT,
role TEXT NOT NULL,
content TEXT NOT NULL,
text_content TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW(),
content_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', text_content)) STORED
);
CREATE INDEX IF NOT EXISTS idx_assistant_msg_parent ON assistant_messages (parent_id);
CREATE INDEX IF NOT EXISTS idx_assistant_msg_session ON assistant_messages (session_id);
CREATE INDEX IF NOT EXISTS idx_assistant_msg_fts ON assistant_messages USING GIN (content_tsv);

CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS cf_agents_context_blocks (
label TEXT PRIMARY KEY,
content TEXT NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS 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)
);
CREATE INDEX IF NOT EXISTS idx_search_entries_fts ON cf_agents_search_entries USING GIN (content_tsv);
```

#### 4. Configure wrangler

```jsonc
{
"compatibility_flags": ["nodejs_compat"],
"hyperdrive": [
{
"binding": "HYPERDRIVE",
"id": "<your-hyperdrive-id>"
}
],
"placement": {
"region": "aws:us-east-1" // match your database region
}
}
```

#### 5. Wire it up

```typescript
import { Agent, callable } from "agents";
import {
Session,
PostgresSessionProvider,
PostgresContextProvider,
PostgresSearchProvider,
type PostgresConnection
} from "agents/experimental/memory/session";
import { Client } from "pg";

// Wrap pg Client to match the PostgresConnection interface
function wrapPgClient(client: Client): PostgresConnection {
return {
async execute(query, args) {
let idx = 0;
const pgQuery = query.replace(/\?/g, () => `$${++idx}`);
const result = await client.query(pgQuery, args ?? []);
return { rows: result.rows };
}
};
}

class MyAgent extends Agent<Env> {
private _session?: Session;
private _pgClient?: Client;

private async getConnection(): Promise<PostgresConnection> {
if (!this._pgClient) {
this._pgClient = new Client({
connectionString: this.env.HYPERDRIVE.connectionString
});
await this._pgClient.connect();
}
return wrapPgClient(this._pgClient);
}

private async getSession(): Promise<Session> {
if (this._session) return this._session;

const conn = await this.getConnection();
const sessionId = this.ctx.id.toString();

this._session = Session.create(new PostgresSessionProvider(conn, sessionId))
.withContext("soul", {
provider: {
get: async () => "You are a helpful assistant."
}
})
.withContext("memory", {
description: "Short facts",
maxTokens: 1100,
provider: new PostgresContextProvider(conn, `memory_${sessionId}`)
})
.withContext("knowledge", {
description: "Searchable knowledge base",
provider: new PostgresSearchProvider(conn)
})
.withCachedPrompt(
new PostgresContextProvider(conn, `_prompt_${sessionId}`)
);

return this._session;
}
}
```

### How it works

When `Session.create()` receives a `SessionProvider` instead of a `SqlProvider`, it skips all SQLite auto-wiring. This means:

- **Context blocks need explicit providers.** No auto-wiring to SQLite — each `withContext()` call needs a `provider` option, or the block will be read-only with no storage.
- **`withCachedPrompt()` needs an explicit provider.** Pass a `PostgresContextProvider` to persist the frozen system prompt.
- **Broadcaster is skipped.** WebSocket status broadcasts (`CF_AGENT_SESSION` events) only work with `SqlProvider`-based sessions.
- **All Session methods are async.** `getHistory()`, `getMessage()`, etc. return Promises since the underlying storage is async.

### System prompt lifecycle

- **`freezeSystemPrompt()`** — returns the cached prompt from the store. On first call (cache miss), loads blocks from providers, renders, and persists. Subsequent calls return the stored value without re-rendering. This preserves LLM prefix cache hits.
- **`refreshSystemPrompt()`** — force reloads blocks from providers, re-renders, and updates the store. Call this to invalidate the cached prompt (e.g. after `clearMessages`).

### PostgresConnection interface

```typescript
interface PostgresConnection {
execute(
query: string,
args?: (string | number | boolean | null)[]
): Promise<{ rows: Record<string, unknown>[] }>;
}
```

The providers use `?` placeholders internally. When wrapping `pg`, convert to `$1, $2, $3` (see the `wrapPgClient` helper above). Any Postgres driver with a compatible `execute()` method works.

### Search

Two levels of search are available:

- **Message search** — `PostgresSessionProvider.searchMessages()` searches conversation history via the `content_tsv` column on `assistant_messages`.
- **Knowledge search** — `PostgresSearchProvider` provides a searchable context block backed by `cf_agents_search_entries`. The LLM can index content via `set_context` and query it via `search_context`. Uses `tsvector` + GIN index with English stemming and `ts_rank` for relevance ranking.

The migration SQL above includes both tables with tsvector columns and GIN indexes — search works out of the box.

---

## Utilities

Exported from `agents/experimental/memory/utils`:
Expand Down Expand Up @@ -719,6 +910,9 @@ import {
AgentContextProvider,
AgentSearchProvider,
R2SkillProvider,
PostgresSessionProvider,
PostgresContextProvider,
PostgresSearchProvider,

// Type guards
isWritableProvider,
Expand All @@ -741,7 +935,8 @@ import {
type SearchResult,
type SessionProvider,
type StoredCompaction,
type SqlProvider
type SqlProvider,
type PostgresConnection
} from "agents/experimental/memory/session";
```

Expand Down
4 changes: 2 additions & 2 deletions examples/resumable-stream-chat/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,12 @@ function Chat() {
ChatMessage
>({
agent,
onData(part) {
onData(part: { type: string; data: unknown }) {
// Capture transient thinking parts from the onData callback.
// These are ephemeral — not persisted and not in message.parts.
if (part.type === "data-thinking") {
// part.data is typed as ThinkingData here — no cast needed
setThinkingData(part.data);
setThinkingData(part.data as ThinkingData);
}
}
});
Expand Down
12 changes: 6 additions & 6 deletions experimental/session-memory/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export class ChatAgent extends Agent<Env> {
parts: [{ type: "text", text: message }]
});

const history = this.session.getHistory();
const history = await this.session.getHistory();
const truncated = truncateOlderMessages(history);

const result = streamText({
Expand Down Expand Up @@ -139,18 +139,18 @@ export class ChatAgent extends Agent<Env> {
}

@callable()
getMessages(): UIMessage[] {
return this.session.getHistory() as UIMessage[];
async getMessages(): Promise<UIMessage[]> {
return (await this.session.getHistory()) as UIMessage[];
}

@callable()
search(query: string) {
async search(query: string) {
return this.session.search(query);
}

@callable()
clearMessages(): void {
this.session.clearMessages();
async clearMessages(): Promise<void> {
await this.session.clearMessages();
}
}

Expand Down
10 changes: 5 additions & 5 deletions experimental/session-multichat/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ export class MultiSessionAgent extends Agent<Env> {
}

@callable()
deleteChat(chatId: string) {
this.manager.delete(chatId);
async deleteChat(chatId: string) {
await this.manager.delete(chatId);
}

// ── Chat ──────────────────────────────────────────────────────
Expand All @@ -100,7 +100,7 @@ export class MultiSessionAgent extends Agent<Env> {
parts: [{ type: "text", text: message }]
});

const history = session.getHistory();
const history = await session.getHistory();
const truncated = truncateOlderMessages(history);

const result = streamText({
Expand Down Expand Up @@ -148,8 +148,8 @@ export class MultiSessionAgent extends Agent<Env> {
}

@callable()
getHistory(chatId: string): UIMessage[] {
return this.manager.getSession(chatId).getHistory() as UIMessage[];
async getHistory(chatId: string): Promise<UIMessage[]> {
return (await this.manager.getSession(chatId).getHistory()) as UIMessage[];
}

@callable()
Expand Down
84 changes: 84 additions & 0 deletions experimental/session-planetscale/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# PlanetScale Session Example

Agent with session history stored in PlanetScale (MySQL) instead of Durable Object SQLite.

## Why PlanetScale?

DO SQLite is great for per-user state, but sessions live and die with the DO. PlanetScale gives you:

- **Cross-DO queries** — search across all conversations from any Worker
- **Analytics** — run SQL against your conversation data directly
- **Decoupled lifecycle** — session data survives DO eviction, migration, and resets
- **Shared state** — multiple DOs or services can read/write the same session tables

## Setup

### 1. Create a PlanetScale database

Sign up at [planetscale.com](https://planetscale.com) and create a database. The free hobby tier works fine for development.

### 2. Get connection credentials

In the PlanetScale dashboard → your database → **Connect** → choose `@planetscale/database` → copy the host, username, and password.

### 3. Set Worker secrets

```bash
wrangler secret put PLANETSCALE_HOST
# paste: your-db-xxxxxxx.us-east-2.psdb.cloud

wrangler secret put PLANETSCALE_USERNAME
# paste: your username

wrangler secret put PLANETSCALE_PASSWORD
# paste: your password
```
Comment on lines +26 to +35
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.

🔴 session-planetscale README describes PlanetScale/MySQL setup but code uses Postgres/Hyperdrive

The README describes setting up PlanetScale secrets (PLANETSCALE_HOST, PLANETSCALE_USERNAME, PLANETSCALE_PASSWORD) and references @planetscale/database (a MySQL driver), but the actual server code imports Client from pg (PostgreSQL), uses PostgresSessionProvider/PostgresContextProvider/PostgresSearchProvider, and connects via this.env.HYPERDRIVE.connectionString. The env.d.ts declares HYPERDRIVE: Hyperdrive, not PlanetScale secrets. Users following the README's setup instructions (lines 26–35) would configure credentials the code never reads.

Prompt for agents
The session-planetscale README describes a PlanetScale/MySQL setup (individual secrets for host, username, password, and @planetscale/database driver) but the actual implementation in src/server.ts uses pg (PostgreSQL) with Cloudflare Hyperdrive. The env.d.ts declares HYPERDRIVE: Hyperdrive, and wrangler.jsonc configures a hyperdrive binding. The entire README sections 1-3 (Create a PlanetScale database, Get connection credentials, Set Worker secrets) need to be rewritten to describe the actual Postgres + Hyperdrive setup: create a Postgres database, create a Hyperdrive config with wrangler, and reference the binding. Alternatively, rename the example from session-planetscale to session-postgres to match the implementation.
Open in Devin Review

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


### 4. Deploy

```bash
npm install
wrangler deploy
```

Tables (`assistant_messages`, `assistant_compactions`, `cf_agents_context_blocks`) are auto-created on first request.
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.

🔴 session-planetscale README falsely claims tables are auto-created

The README states "Tables (assistant_messages, assistant_compactions, cf_agents_context_blocks) are auto-created on first request" but PostgresSessionProvider (packages/agents/src/experimental/memory/session/providers/postgres.ts) has no table creation logic whatsoever. The main docs at docs/sessions.md:688 correctly say "Run this once in your database console" with manual SQL. Users following the example's README will hit runtime errors on first request because the tables don't exist.

Suggested change
Tables (`assistant_messages`, `assistant_compactions`, `cf_agents_context_blocks`) are auto-created on first request.
Tables must be created before first use. Run the migration SQL from [the docs](../../docs/sessions.md#3-create-the-tables) in your database console.
Open in Devin Review

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


## How it works

The key difference from the standard `session-memory` example:

```ts
// Standard: auto-wires to DO SQLite
const session = Session.create(this)
.withContext("memory", { maxTokens: 1100 })
.withCachedPrompt();

// PlanetScale: pass providers explicitly
const conn = connect({ host, username, password });

const session = Session.create(new PlanetScaleSessionProvider(conn, sessionId))
.withContext("memory", {
maxTokens: 1100,
provider: new PlanetScaleContextProvider(conn, `memory_${sessionId}`)
})
.withCachedPrompt(
new PlanetScaleContextProvider(conn, `_prompt_${sessionId}`)
);
```

When `Session.create()` receives a `SessionProvider` (not a `SqlProvider`), it skips all SQLite auto-wiring. Context blocks and the prompt cache need explicit providers since there's no DO storage to fall back to.

## Connection interface

The providers work with `@planetscale/database` out of the box, but any driver matching this interface works:

```ts
interface PlanetScaleConnection {
execute(
query: string,
args?: (string | number | boolean | null)[]
): Promise<{ rows: Record<string, unknown>[] }>;
}
```

This means you can also use [Neon](https://neon.tech), [Turso](https://turso.tech), or any MySQL/Postgres driver with a compatible `execute()` method.
7 changes: 7 additions & 0 deletions experimental/session-planetscale/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
declare namespace Cloudflare {
interface Env {
AI: Ai;
HYPERDRIVE: Hyperdrive;
}
}
interface Env extends Cloudflare.Env {}
Loading
Loading