cloudflare / cloudflare/agents
Expose async persistence hooks for AIChatAgent so non-SQLite backends do not need to override persistMessages()
- 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
Assessment
This issue has not been assessed yet.