cloudflare / cloudflare/agents

Expose async persistence hooks for AIChatAgent so non-SQLite backends do not need to override persistMessages()

Open
#1,283 0 comments 0 reactions 1 assignee Claimed by @threepointone View on GitHub
enhancement
Dominant language
TypeScript
Stars
5.6k
Forks
711
Avg merge
1d 20h
Merged PRs (30d)
53

Description

## Summary

`AIChatAgent` currently couples two separate responsibilities:

1. chat transcript semantics
2. SQLite persistence inside the Durable Object

That works well for the default DO/SQLite case, but it makes external storage backends awkward and brittle.

Today, the main extension point is overriding `persistMessages()`. For a Postgres-backed agent, that means reimplementing logic that really belongs to the framework:

- message reconciliation
- assistant ID reconciliation
- tool-call merge behavior
- stale-row deletion rules
- persistence sanitization
- in-memory transcript updates vs broadcast transcript shape

We recently hit a real bug caused by this: our Postgres override drifted from upstream `persistMessages()` behavior and corrupted chat state during overlapping submits.

Related but distinct from #1068: that issue is about a stream-only mode that disables SQLite message persistence. This issue is about exposing a lower-level async storage contract so non-SQLite backends do not need to fork framework-owned semantics.

## Problem

Overriding `persistMessages()` is too high-level for storage customization.

A storage backend should only answer:

- how to load messages
- how to upsert messages
- how to delete messages
- optionally how to persist request context / resumable stream state

It should not need to duplicate framework-owned transcript semantics.

Right now, anyone using Postgres or another external store has to vendor private `AIChatAgent` behavior locally and keep it in sync with upstream changes. That is error-prone.

## Requested change

Expose lower-level async persistence hooks so `AIChatAgent` can keep owning chat semantics while storage becomes swappable.

The key requirement is that these hooks must be async and not constructor-bound.

The current SQLite path can stay the default implementation.

## What the framework should own

`AIChatAgent` should continue to own:

- `reconcileMessages(...)`
- assistant/content-key reconciliation
- tool-call merge behavior
- `_deleteStaleRows` subset checks
- built-in persistence sanitization
- row-size / max-persisted-message policies, if applicable
- `this.messages` lifecycle
- broadcast behavior

## What a backend adapter should own

A storage adapter should only own CRUD-like persistence operations.

Something in this shape would be enough:

```ts
interface ChatMessageStore {
loadMessages(): Promise
upsertMessages(messages: UIMessage[]): Promise
deleteMessages(ids: string[]): Promise
}
```

Or, if you want to keep it closer to the current class shape:

```ts
protected async loadPersistedMessages(): Promise
protected async upsertPersistedMessages(messages: UIMessage[]): Promise
protected async deletePersistedMessages(ids: string[]): Promise
```

Then `persistMessages()` can remain framework-owned and final, composed on top of those hooks.

## Important detail: async initialization

For Postgres-backed agents, synchronous constructor-time loading does not work well.

So if storage hooks are added, the framework likely also needs an async initialization path for message loading, rather than assuming local SQLite is available immediately in the constructor.

## Nice follow-up, but not required for the first step

The same pattern would also help for:

- request context persistence
- resumable stream chunk persistence

But message persistence alone would already remove the biggest source of semantic drift.

## Why this matters

Without lower-level hooks, custom backends are forced to fork internal persistence behavior.

That creates a maintenance trap:

- upstream adds/fixes persistence semantics
- downstream overrides silently drift
- correctness bugs show up in edge cases like queued submits, tool continuations, or regeneration

A smaller async storage contract would let non-SQLite backends plug in cleanly while keeping the chat state machine in one place.

## Expected outcome

With this change, a Postgres-backed chat agent should be able to swap storage without reimplementing `persistMessages()` semantics locally.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.