agents 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +20 -1
  2. package/dist/{agent-routing-CnkaHb-v.d.ts → agent-routing-DE5zmCQ8.d.ts} +1038 -394
  3. package/dist/agent-routing.d.ts +1 -1
  4. package/dist/agent-tool-types.d.ts +26 -26
  5. package/dist/{agent-tools-C0XJqWzB.d.ts → agent-tools-DtXMTDGM.d.ts} +7 -7
  6. package/dist/agent-tools.d.ts +21 -21
  7. package/dist/browser/ai.js +4 -1
  8. package/dist/browser/ai.js.map +1 -1
  9. package/dist/browser/tanstack-ai.js.map +1 -1
  10. package/dist/callable-decorator-DP__HhBA.d.ts +72 -0
  11. package/dist/callable-decorator.d.ts +18 -0
  12. package/dist/callable-decorator.js +71 -0
  13. package/dist/callable-decorator.js.map +1 -0
  14. package/dist/capability-BjSKYpzg.js.map +1 -1
  15. package/dist/capability-runner-Be_-PLR1.d.ts +459 -0
  16. package/dist/channel-Bnm4S7T2.d.ts +491 -0
  17. package/dist/channels/ai-sdk.d.ts +46 -0
  18. package/dist/channels/ai-sdk.js +120 -0
  19. package/dist/channels/ai-sdk.js.map +1 -0
  20. package/dist/channels/email.d.ts +95 -0
  21. package/dist/channels/email.js +323 -0
  22. package/dist/channels/email.js.map +1 -0
  23. package/dist/channels/index.d.ts +233 -0
  24. package/dist/channels/index.js +608 -0
  25. package/dist/channels/index.js.map +1 -0
  26. package/dist/channels/slack.d.ts +140 -0
  27. package/dist/channels/slack.js +614 -0
  28. package/dist/channels/slack.js.map +1 -0
  29. package/dist/channels/tanstack-ai.d.ts +39 -0
  30. package/dist/channels/tanstack-ai.js +17 -0
  31. package/dist/channels/tanstack-ai.js.map +1 -0
  32. package/dist/channels/telegram.d.ts +106 -0
  33. package/dist/channels/telegram.js +427 -0
  34. package/dist/channels/telegram.js.map +1 -0
  35. package/dist/channels/voice.d.ts +45 -0
  36. package/dist/channels/voice.js +122 -0
  37. package/dist/channels/voice.js.map +1 -0
  38. package/dist/chat/index.d.ts +2325 -2019
  39. package/dist/chat/index.js +888 -518
  40. package/dist/chat/index.js.map +1 -1
  41. package/dist/chat-sdk/index.d.ts +7 -7
  42. package/dist/chat-sdk/index.js +1 -1
  43. package/dist/client.d.ts +1 -1
  44. package/dist/context/index.d.ts +216 -0
  45. package/dist/context/index.js +454 -0
  46. package/dist/context/index.js.map +1 -0
  47. package/dist/{current-agent-CuMErtly.d.ts → current-agent-Da_C9a3b.d.ts} +90 -107
  48. package/dist/current-agent-DhoDkSnH.js.map +1 -1
  49. package/dist/{diagnostics-CaBjfz4J.js → diagnostics-BzvaX2UT.js} +5 -1
  50. package/dist/diagnostics-BzvaX2UT.js.map +1 -0
  51. package/dist/diagnostics-C4jcz3VK.js +360 -0
  52. package/dist/diagnostics-C4jcz3VK.js.map +1 -0
  53. package/dist/index-BB0kqhIz.d.ts +101 -0
  54. package/dist/index-BVVgDSdq.d.ts +1 -0
  55. package/dist/index-XDkuQ7zm.d.ts +89 -0
  56. package/dist/{index-DcSAZKsB.d.ts → index-YSKgfgg9.d.ts} +3 -1
  57. package/dist/index.d.ts +91 -82
  58. package/dist/index.js +3 -2
  59. package/dist/ingress-BfetZbMO.js +83 -0
  60. package/dist/ingress-BfetZbMO.js.map +1 -0
  61. package/dist/internal-CYlgHl1l.js +59 -0
  62. package/dist/internal-CYlgHl1l.js.map +1 -0
  63. package/dist/internal_context.d.ts +1 -1
  64. package/dist/lifecycle/index.d.ts +35 -17
  65. package/dist/lifecycle/index.js +1 -1
  66. package/dist/lifecycle-CMRGjZdw.js +1299 -0
  67. package/dist/lifecycle-CMRGjZdw.js.map +1 -0
  68. package/dist/mcp/client/index.d.ts +20 -20
  69. package/dist/mcp/index.d.ts +35 -35
  70. package/dist/mcp/index.js +1 -1
  71. package/dist/observability/index.d.ts +1 -1
  72. package/dist/observability/index.js +1 -1
  73. package/dist/react.d.ts +4 -4
  74. package/dist/{retries-CAvxtG9d.d.ts → retries-D9Ds-1lz.d.ts} +17 -6
  75. package/dist/retries.d.ts +8 -6
  76. package/dist/retries.js +13 -1
  77. package/dist/retries.js.map +1 -1
  78. package/dist/routing/index.d.ts +137 -0
  79. package/dist/routing/index.js +244 -0
  80. package/dist/routing/index.js.map +1 -0
  81. package/dist/sanitize-D9TujEK8.js +79 -0
  82. package/dist/sanitize-D9TujEK8.js.map +1 -0
  83. package/dist/scheduler-DD9NdYbF.js +665 -0
  84. package/dist/scheduler-DD9NdYbF.js.map +1 -0
  85. package/dist/{scheduler-DQoTGoAW.d.ts → scheduler-Dwh85ZGl.d.ts} +21 -22
  86. package/dist/schedules/index.d.ts +1 -1
  87. package/dist/schedules/index.js +1 -1
  88. package/dist/sentence-chunker-BAidJ4DA.d.ts +68 -0
  89. package/dist/serializable.d.ts +1 -1
  90. package/dist/sessions/index.d.ts +441 -0
  91. package/dist/sessions/index.js +2063 -0
  92. package/dist/sessions/index.js.map +1 -0
  93. package/dist/skills/index.d.ts +99 -0
  94. package/dist/skills/index.js +254 -5
  95. package/dist/skills/index.js.map +1 -1
  96. package/dist/{src-5W6JNKVb.js → src-DlSHshb2.js} +1460 -1110
  97. package/dist/src-DlSHshb2.js.map +1 -0
  98. package/dist/streams/index.d.ts +120 -0
  99. package/dist/streams/index.js +107 -0
  100. package/dist/streams/index.js.map +1 -0
  101. package/dist/streams-D6tJ0NN9.d.ts +370 -0
  102. package/dist/streams-DZKgAj9b.js +709 -0
  103. package/dist/streams-DZKgAj9b.js.map +1 -0
  104. package/dist/sub-routing.d.ts +12 -12
  105. package/dist/surface-bZZJqBka.js +17 -0
  106. package/dist/surface-bZZJqBka.js.map +1 -0
  107. package/dist/tasks/index.d.ts +64 -0
  108. package/dist/tasks/index.js +2 -0
  109. package/dist/tasks-BRJ5zgya.d.ts +517 -0
  110. package/dist/tasks-ylZgBjhj.js +1656 -0
  111. package/dist/tasks-ylZgBjhj.js.map +1 -0
  112. package/dist/text-segment-joiner-BtAFQSA_.js +57 -0
  113. package/dist/text-segment-joiner-BtAFQSA_.js.map +1 -0
  114. package/dist/text-stream-CpdiKrJB.js +272 -0
  115. package/dist/text-stream-CpdiKrJB.js.map +1 -0
  116. package/dist/tokens-nHAKcN6M.js +52 -0
  117. package/dist/tokens-nHAKcN6M.js.map +1 -0
  118. package/dist/tool-schema-CBjGPrsQ.js +31 -0
  119. package/dist/tool-schema-CBjGPrsQ.js.map +1 -0
  120. package/dist/types-B7LojTe4.d.ts +202 -0
  121. package/dist/types-_Faxb570.d.ts +439 -0
  122. package/dist/voice/client.d.ts +226 -0
  123. package/dist/voice/client.js +932 -0
  124. package/dist/voice/client.js.map +1 -0
  125. package/dist/voice/errors.d.ts +43 -0
  126. package/dist/voice/errors.js +41 -0
  127. package/dist/voice/errors.js.map +1 -0
  128. package/dist/voice/index.d.ts +271 -0
  129. package/dist/voice/index.js +1812 -0
  130. package/dist/voice/index.js.map +1 -0
  131. package/dist/voice/react.d.ts +167 -0
  132. package/dist/voice/react.js +234 -0
  133. package/dist/voice/react.js.map +1 -0
  134. package/dist/voice/sfu.d.ts +71 -0
  135. package/dist/voice/sfu.js +157 -0
  136. package/dist/voice/sfu.js.map +1 -0
  137. package/dist/voice/text.d.ts +6 -0
  138. package/dist/voice/text.js +2 -0
  139. package/dist/voice/types.d.ts +58 -0
  140. package/dist/voice/types.js +18 -0
  141. package/dist/voice/types.js.map +1 -0
  142. package/dist/voice/workers-ai.d.ts +136 -0
  143. package/dist/voice/workers-ai.js +568 -0
  144. package/dist/voice/workers-ai.js.map +1 -0
  145. package/dist/websockets/index.d.ts +192 -0
  146. package/dist/websockets/index.js +2 -0
  147. package/dist/websockets-DUfRHPRq.js +502 -0
  148. package/dist/websockets-DUfRHPRq.js.map +1 -0
  149. package/dist/workflow-types.d.ts +25 -25
  150. package/dist/workflows.d.ts +21 -21
  151. package/dist/workflows.js +1 -1
  152. package/docs/agent-class.md +2 -2
  153. package/docs/agent-tools.md +2 -1
  154. package/docs/channels.md +323 -0
  155. package/docs/chat-agents.md +6 -13
  156. package/docs/context.md +131 -0
  157. package/docs/index.md +15 -12
  158. package/docs/lifecycle.md +102 -55
  159. package/docs/long-running-agents.md +2 -2
  160. package/docs/mcp-servers.md +5 -1
  161. package/docs/resumable-streaming.md +1 -1
  162. package/docs/routing.md +105 -0
  163. package/docs/sessions.md +237 -871
  164. package/docs/streams.md +213 -0
  165. package/docs/sub-agents.md +184 -124
  166. package/docs/tasks.md +246 -0
  167. package/docs/voice.md +745 -0
  168. package/package.json +115 -13
  169. package/dist/capability-runner-CvHGZqUu.d.ts +0 -150
  170. package/dist/compaction-helpers-iiKMr2TQ.js +0 -340
  171. package/dist/compaction-helpers-iiKMr2TQ.js.map +0 -1
  172. package/dist/compaction-helpers-wUz6M3us.d.ts +0 -621
  173. package/dist/diagnostics-CaBjfz4J.js.map +0 -1
  174. package/dist/durable-object-lifecycle-D6nNQJJd.js +0 -862
  175. package/dist/durable-object-lifecycle-D6nNQJJd.js.map +0 -1
  176. package/dist/experimental/memory/session/index.d.ts +0 -671
  177. package/dist/experimental/memory/session/index.js +0 -2379
  178. package/dist/experimental/memory/session/index.js.map +0 -1
  179. package/dist/experimental/memory/utils/index.d.ts +0 -96
  180. package/dist/experimental/memory/utils/index.js +0 -79
  181. package/dist/experimental/memory/utils/index.js.map +0 -1
  182. package/dist/scheduler-CR9RHGos.js +0 -857
  183. package/dist/scheduler-CR9RHGos.js.map +0 -1
  184. package/dist/src-5W6JNKVb.js.map +0 -1
  185. package/dist/tool-output-truncation-CNnnGZQ3.js +0 -98
  186. package/dist/tool-output-truncation-CNnnGZQ3.js.map +0 -1
package/docs/sessions.md CHANGED
@@ -1,1024 +1,390 @@
1
- # Sessions (Experimental)
1
+ # Sessions
2
2
 
3
- The Session API provides persistent conversation storage for agents, with tree-structured messages, context blocks, compaction, full-text search, and AI-controllable tools. By default it uses Durable Object SQLite; external Postgres storage is also available for apps that need shared database access, analytics, or cross-DO queries.
3
+ > **Experimental.** Everything exported from `agents/sessions` may change between releases while the API stabilizes.
4
4
 
5
- > **Experimental.** The Session API is under `agents/experimental/memory/session`. The API surface is stable but may evolve before graduating to the main package.
5
+ `agents/sessions` stores durable conversation history in a [Lifecycle Object](./lifecycle.md). It provides tree-structured messages, streamed and byte-budgeted reads, compaction overlays, optional full-text search, and lossless payload storage.
6
6
 
7
- ## Quick Start
7
+ **Sessions stores messages. It is not a file store.** Media you attach to a message is kept out of the message row and stored separately, addressed by content, and put back verbatim when you read. That keeps a message row small however large its attachments are — but it does not reclaim database space, because the payload lives in the same Durable Object. A Durable Object's 10 GB ceiling is therefore the real bound on how much media one conversation can hold: roughly 39,000 200 KB images, measured. An application that handles files should keep them in a file store and put a reference in the message. Think does exactly that with its [Workspace](../think/index.md), which spills to R2.
8
8
 
9
- ```typescript
10
- import { Agent } from "agents";
11
- import { Session } from "agents/experimental/memory/session";
9
+ Prompt assembly lives in [`agents/context`](./context.md) and composes with a session handle rather than living inside it.
12
10
 
13
- class MyAgent extends Agent {
14
- session = Session.create(this)
15
- .withContext("soul", {
16
- provider: { get: async () => "You are a helpful assistant." }
17
- })
18
- .withContext("memory", {
19
- description: "Learned facts about the user",
20
- maxTokens: 1100
21
- })
22
- .withCachedPrompt();
23
-
24
- async onMessage(message) {
25
- await this.session.appendMessage(message);
26
- const history = await this.session.getHistory();
27
- const system = await this.session.freezeSystemPrompt();
28
- const tools = await this.session.tools();
29
- // Pass history, system prompt, and tools to your LLM
30
- }
31
- }
32
- ```
33
-
34
- ## Session
11
+ `Think` and `AIChatAgent` use this capability for message persistence. You can also install it on a plain Durable Object.
35
12
 
36
- `Session` manages a single conversation's messages, context blocks, and compaction state.
13
+ ## Install the capability
37
14
 
38
- ### Creating a Session
15
+ ```ts
16
+ import { DurableObject } from "cloudflare:workers";
17
+ import { Lifecycle } from "agents/lifecycle";
18
+ import { Sessions } from "agents/sessions";
39
19
 
40
- There are two ways to create a Session:
41
-
42
- **Builder API (recommended)** — uses `Session.create(agent)` with a chainable builder. Context providers without an explicit `provider` option are auto-wired to SQLite.
43
-
44
- ```typescript
45
- const session = Session.create(this)
46
- .withContext("soul", { provider: { get: async () => "You are helpful." } })
47
- .withContext("memory", { description: "Learned facts", maxTokens: 1100 })
48
- .withCachedPrompt()
49
- .onCompaction(myCompactFn)
50
- .compactAfter(100_000);
51
- ```
52
-
53
- **Direct constructor** — takes a `SessionProvider` and options directly. Used when you want full control over providers.
54
-
55
- ```typescript
56
- import {
57
- AgentSessionProvider,
58
- AgentContextProvider
59
- } from "agents/experimental/memory/session";
60
-
61
- const session = new Session(new AgentSessionProvider(this), {
62
- context: [
63
- {
64
- label: "memory",
65
- description: "Notes",
66
- maxTokens: 500,
67
- provider: new AgentContextProvider(this, "memory")
68
- },
69
- { label: "soul", provider: { get: async () => "You are helpful." } }
70
- ]
71
- });
20
+ export class ConversationObject extends DurableObject<Env> {
21
+ readonly sessions = new Sessions();
22
+ readonly lifecycle = Lifecycle.install(this).use(this.sessions);
23
+ readonly session = this.sessions.session();
24
+ }
72
25
  ```
73
26
 
74
- ### Builder Methods
75
-
76
- All builder methods return `this` for chaining. Order does not matter — providers are resolved lazily on first use.
77
-
78
- | Method | Description |
79
- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
- | `Session.create(agent)` | Static factory. `agent` is any object with a `sql` tagged template method (i.e. your Agent/DO). |
81
- | `.forSession(sessionId)` | Namespace this session by ID. Required for multi-session isolation when not using SessionManager. Context provider keys and storage are scoped to this ID. |
82
- | `.withContext(label, options?)` | Add a context block. See [Context Blocks](#context-blocks). |
83
- | `.withCachedPrompt(provider?)` | Enable system prompt persistence. The prompt is frozen on first use and survives DO hibernation/eviction. Without an explicit provider, auto-wires to SQLite. |
84
- | `.onCompaction(fn)` | Register a compaction function. See [Compaction](#compaction). |
85
- | `.compactAfter(tokenThreshold, options?)` | Auto-compact when estimated token count exceeds the threshold. Checked after each `appendMessage()`. Requires `.onCompaction()`. |
86
- | `.onCompactionError(handler)` | Handle errors from automatic compaction. Handler failures are swallowed so message writes remain non-fatal. |
87
-
88
- ### Messages
89
-
90
- Messages use the `SessionMessage` type — a minimal shape with `id`, `role`, `parts`, and optional `createdAt`. The Vercel AI SDK's `UIMessage` is structurally compatible and can be passed directly without conversion. The session stores messages in a tree structure via `parent_id`, enabling branching conversations.
91
-
92
- ```typescript
93
- // Append — auto-parents to the latest leaf unless parentId is specified
94
- await session.appendMessage(message);
95
- await session.appendMessage(message, parentId);
27
+ On an `Agent`, install it in the constructor:
96
28
 
97
- // Update an existing message (matched by message.id)
98
- await session.updateMessage(message);
29
+ ```ts
30
+ import { Agent, type AgentContext } from "agents";
31
+ import { Sessions } from "agents/sessions";
99
32
 
100
- // Delete specific messages
101
- await session.deleteMessages(["msg-1", "msg-2"]);
33
+ export class ConversationAgent extends Agent<Env> {
34
+ readonly sessions = new Sessions();
35
+ readonly session = this.sessions.session();
102
36
 
103
- // Clear all messages and skill state
104
- await session.clearMessages();
37
+ constructor(ctx: AgentContext, env: Env) {
38
+ super(ctx, env);
39
+ this.lifecycle.use(this.sessions);
40
+ }
41
+ }
105
42
  ```
106
43
 
107
- > **Note:** Session methods are async. SQLite-backed sessions are usually fast, but external providers may perform network I/O, and `appendMessage()` may also trigger auto-compaction.
44
+ The default session ID is an empty string. This is the primary path when one Durable Object owns one conversation.
108
45
 
109
- #### Reading History
110
-
111
- ```typescript
112
- // Linear history from root to the latest leaf
113
- const messages = await session.getHistory();
46
+ Sessions needs no alarm, so it also works on facets, which have isolated SQLite but no independent alarm slot.
114
47
 
115
- // History to a specific leaf (for branching)
116
- const branch = await session.getHistory(leafId);
48
+ ## Write messages
117
49
 
118
- // Get a single message
119
- const msg = await session.getMessage("msg-1");
50
+ `UIMessage` from the AI SDK is structurally compatible with `SessionMessage`.
120
51
 
121
- // Get the newest message
122
- const latest = await session.getLatestLeaf();
52
+ ```ts
53
+ const result = await this.session.appendMessage({
54
+ id: crypto.randomUUID(),
55
+ role: "user",
56
+ parts: [{ type: "text", text: "Hello" }]
57
+ });
123
58
 
124
- // Count messages in path
125
- const count = await session.getPathLength();
59
+ result.inserted; // false when this ID already existed
60
+ result.message; // exact stored form
126
61
  ```
127
62
 
128
- #### Branching
129
-
130
- Messages form a tree. When you `appendMessage` with a `parentId` that already has children, you create a branch. Use `getBranches()` to get all child messages branching from a given point:
63
+ An append without `parentId` attaches to the active leaf. Pass `null` to create a root or pass a message ID to create a branch:
131
64
 
132
- ```typescript
133
- // Get all child messages that branch from messageId (e.g. multiple responses to a user message)
134
- const branches = await session.getBranches(messageId);
65
+ ```ts
66
+ await this.session.appendMessage(alternativeReply, {
67
+ parentId: userMessage.id
68
+ });
135
69
  ```
136
70
 
137
- This powers features like response regeneration pass the user message ID to get both the original and regenerated responses. `getHistory(leafId)` walks the chosen path.
71
+ Every write runs the same pipeline: sanitize provider metadata, strip reserved metadata on client input, then commit the row and any continuation rows in one synchronous SQLite transaction.
138
72
 
139
- ### Search
73
+ Mark untrusted input with `source: "client"`:
140
74
 
141
- Full-text search over the conversation history. SQLite-backed sessions use FTS5; Postgres-backed sessions use the provider's Postgres full-text index.
75
+ ```ts
76
+ const sessions = new Sessions({
77
+ reservedMetadataKeys: ["channel", "turnMetadata"]
78
+ });
142
79
 
143
- ```typescript
144
- const results = await session.search("deployment Friday", { limit: 10 });
145
- // Returns: Array<{ id, role, content, createdAt? }>
80
+ await sessions.session().appendMessage(clientMessage, {
81
+ source: "client"
82
+ });
146
83
  ```
147
84
 
148
- Uses porter stemming and unicode tokenization. The search covers all messages in the session.
149
-
150
- > **Note:** `search()` throws if the session provider does not support search. The built-in `AgentSessionProvider` and `PostgresSessionProvider` support it.
85
+ This strips reserved metadata keys. It does not limit message size see [There is no upper bound on a message](#there-is-no-upper-bound-on-a-message).
151
86
 
152
- ### WebSocket Broadcasts
87
+ Other writes:
153
88
 
154
- When the Session's `agent` object has a `broadcast()` method (all `Agent` subclasses do), the Session automatically broadcasts status events over WebSocket after each write operation:
89
+ ```ts
90
+ await session.updateMessage(message); // SessionMessage | null
91
+ await session.upsertMessage(message);
92
+ await session.deleteMessages([messageId]);
93
+ await session.clearMessages();
94
+ ```
155
95
 
156
- - **`CF_AGENT_SESSION`** phase (`"idle"` or `"compacting"`), `tokenEstimate`, `tokenThreshold`
157
- - **`CF_AGENT_SESSION_ERROR`** — emitted on compaction failure
96
+ `updateMessage()` returns the stored form of the message, or `null` when the ID is not in this session. It does not throw for an absent row. An unchanged message writes nothing and dispatches no change event.
158
97
 
159
- This allows connected clients to display real-time token usage and compaction status.
98
+ Deleting a message splices its children to its parent. Removing a message in the middle of a chain does not make older history unreachable.
160
99
 
161
- ---
100
+ ### Row budget
162
101
 
163
- ## Context Blocks
102
+ Sessions stores messages. One SQLite row holds up to 1.5 MiB of serialized JSON, which is more than the overwhelming majority of messages need: they occupy exactly one row, and cost exactly one billed row write.
164
103
 
165
- Context blocks are persistent key-value sections injected into the system prompt. Each block has a **label**, optional **description**, and a **provider** that determines its behavior.
104
+ A message that does not fit is split across continuation rows and reassembled on read. Nothing is truncated, nothing is summarized, and no message is too large to store — a 5 MB message is one message row plus three continuation rows, and reads back byte for byte. There is no error to catch and nothing to configure.
166
105
 
167
- ### Provider Types
106
+ Slices are cut on UTF-8 **byte** boundaries, never in the middle of a surrogate pair, so a message full of emoji or CJK text round-trips exactly like an ASCII one.
168
107
 
169
- There are four provider types, detected by duck-typing:
108
+ ### There is no upper bound on a message
170
109
 
171
- | Provider | Interface | Behavior | AI Tool |
172
- | --------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------- |
173
- | **ContextProvider** | `get()` | Read-only block in system prompt | — |
174
- | **WritableContextProvider** | `get()` + `set()` | Writable via AI | `set_context` |
175
- | **SkillProvider** | `get()` + `load()` + `set?()` | On-demand keyed documents. `get()` returns a metadata listing; `load(key)` fetches full content. | `load_context`, `unload_context`, `set_context` |
176
- | **SearchProvider** | `get()` + `search()` + `set?()` | Full-text searchable entries. `get()` returns a summary; `search(query)` runs FTS5. | `search_context`, `set_context` |
110
+ This is a deliberate position, not an omission: Sessions imposes no maximum message size. A message is split across as many continuation rows as it needs, so a single very large write can consume a meaningful share of the Durable Object's 10 GB.
177
111
 
178
- All providers also support an optional `init(label)` method, called before first use with the block's label.
112
+ Bounding the size of untrusted input is the application's job. In particular, `appendMessage(message, { source: "client" })` sanitizes provider metadata and strips reserved metadata keys — it does **not** limit size. If clients can write to a session, check the size of what they send before you append it.
179
113
 
180
- ### Built-in Providers
114
+ An application that handles files should keep them in a file store (R2, or the Workspace) and put a reference in the message.
181
115
 
182
- **`AgentContextProvider`** SQLite-backed writable context. This is what you get by default when using the builder without an explicit provider.
116
+ ## Read history
183
117
 
184
- ```typescript
185
- import { AgentContextProvider } from "agents/experimental/memory/session";
118
+ Prefer streamed reads for unbounded history:
186
119
 
187
- // Explicit usage — key determines the SQLite row
188
- new AgentContextProvider(this, "memory");
120
+ ```ts
121
+ for await (const message of session.history()) {
122
+ await consume(message);
123
+ }
189
124
  ```
190
125
 
191
- **`R2SkillProvider`** Cloudflare R2 bucket for on-demand document loading. Skills are listed in the system prompt as metadata; the model loads full content on demand via `load_context`.
126
+ The capability first reads a content-free path of IDs and row sizes. It then fetches content in queries bounded to 50 rows and 4 MiB. Earlier chunks are not retained by the iterator.
192
127
 
193
- ```typescript
194
- import { R2SkillProvider } from "agents/experimental/memory/session";
128
+ Read from the leaf when you are looking for something recent. The newest content window is fetched first, so breaking out of the loop leaves every older row unread:
195
129
 
196
- Session.create(this).withContext("skills", {
197
- provider: new R2SkillProvider(env.SKILLS_BUCKET, { prefix: "skills/" })
198
- });
130
+ ```ts
131
+ for await (const message of session.history({ newestFirst: true })) {
132
+ if (ownsToolCall(message, toolCallId)) {
133
+ await session.updateMessage(applyResult(message));
134
+ break;
135
+ }
136
+ }
199
137
  ```
200
138
 
201
- Descriptions are stored in R2 custom metadata (`description` key).
202
-
203
- **`AgentSearchProvider`** — SQLite FTS5 searchable context. Entries are indexed and searchable by the model via `search_context`.
139
+ A newest-first read follows parent pointers from the leaf, one row per message the loop actually takes, so its cost has no transcript term. Compaction overlays are honored: the walk stays row-by-row until it reaches the end of a compacted span, and only then plans the overlays over the remaining prefix and streams it leaf-first in eight-row windows.
204
140
 
205
- ```typescript
206
- import { AgentSearchProvider } from "agents/experimental/memory/session";
141
+ Use `historyBatches()` when each downstream operation has a fixed cost:
207
142
 
208
- Session.create(this).withContext("knowledge", {
209
- description: "Searchable knowledge base",
210
- provider: new AgentSearchProvider(this)
211
- });
143
+ ```ts
144
+ for await (const batch of session.historyBatches({
145
+ batchSize: 25,
146
+ maxBatchBytes: 2 * 1024 * 1024
147
+ })) {
148
+ await sendBatch(batch);
149
+ }
212
150
  ```
213
151
 
214
- ### Adding and Removing Context at Runtime
215
-
216
- Blocks can be added and removed dynamically after initialization — useful for extensions:
152
+ Use a byte-budgeted recent window during Durable Object startup:
217
153
 
218
- ```typescript
219
- // Add a new block (auto-wires to SQLite if no provider given)
220
- await session.addContext("extension-notes", {
221
- description: "From extension X",
222
- maxTokens: 500
223
- });
154
+ ```ts
155
+ const recent = await session.getRecentHistory(8 * 1024 * 1024, 4);
224
156
 
225
- // Remove it
226
- session.removeContext("extension-notes");
227
-
228
- // Rebuild the system prompt to reflect changes
229
- await session.refreshSystemPrompt();
157
+ recent.messages;
158
+ recent.truncated;
159
+ recent.totalContentBytes;
230
160
  ```
231
161
 
232
- > **Note:** `addContext` and `removeContext` do NOT automatically update the frozen system prompt. You must call `refreshSystemPrompt()` afterward.
233
-
234
- ### Reading Context Blocks
235
-
236
- ```typescript
237
- // Single block
238
- const block = session.getContextBlock("memory");
239
- // block: { label, description?, content, tokens, maxTokens?, writable, isSkill, isSearchable }
240
-
241
- // All blocks
242
- const blocks = session.getContextBlocks();
243
- ```
162
+ The second argument is a minimum number of recent messages. The minimum can exceed the byte budget, so choose it deliberately.
244
163
 
245
- ### Writing to Context Blocks
164
+ The budget counts what hydration actually costs. Each row is charged its full stored size — the message row plus every continuation row it was split across — so a 12 MB message is charged 12 MB, not the 1.5 MiB its first slice occupies. That is what makes the budget a bound on isolate memory.
246
165
 
247
- ```typescript
248
- // Replace content entirely
249
- await session.replaceContextBlock("memory", "User likes coffee.");
166
+ `getHistory()` materializes the selected path and exists for consumers that require an array:
250
167
 
251
- // Append content
252
- await session.appendContextBlock("memory", "\nUser prefers dark roast.");
168
+ ```ts
169
+ const messages = await session.getHistory();
253
170
  ```
254
171
 
255
- > **Note:** Writing to a context block updates the provider immediately but does NOT update the frozen system prompt snapshot. This is intentional it preserves the LLM prefix cache. Call `refreshSystemPrompt()` when you want changes reflected in the prompt.
256
-
257
- ### System Prompt
258
-
259
- The system prompt is built from all context blocks with headers and metadata:
260
-
261
- ```
262
- ══════════════════════════════════════════════
263
- SOUL (Identity) [readonly]
264
- ══════════════════════════════════════════════
265
- You are a helpful assistant.
266
-
267
- ══════════════════════════════════════════════
268
- MEMORY (Learned facts) [45% — 495/1100 tokens]
269
- ══════════════════════════════════════════════
270
- User likes coffee.
271
- User prefers dark roast.
272
- ```
172
+ Do not use `getHistory()` for an unbounded transcript inside a memory-constrained Durable Object. Prefer `history()`, `historyBatches()`, or `getRecentHistory()`.
273
173
 
274
- ```typescript
275
- // Freeze — first call renders and persists, subsequent calls return the cached value
276
- const prompt = await session.freezeSystemPrompt();
174
+ Other reads:
277
175
 
278
- // Refresh — re-render from current block state and persist
279
- const updated = await session.refreshSystemPrompt();
176
+ ```ts
177
+ await session.getMessage(id);
178
+ await session.getLatestLeaf();
179
+ await session.getBranches(parentId);
180
+ await session.getHistoryRowStats();
280
181
  ```
281
182
 
282
- The frozen prompt survives DO hibernation and eviction when `withCachedPrompt()` is enabled. After eviction, the next `freezeSystemPrompt()` call loads from the configured prompt store rather than re-rendering.
183
+ `getHistoryRowStats()` returns per-row stored bytes (row, continuation rows, and attachments) and stamped token estimates, without loading message content.
283
184
 
284
- ### Skills (Load/Unload)
185
+ ## Branches
285
186
 
286
- Skills are on-demand documents stored in a `SkillProvider` (e.g. R2). The model sees a metadata listing in the system prompt and can load full content on demand:
187
+ A message can have multiple children. Read one root-to-leaf path by selecting its leaf:
287
188
 
288
- ```typescript
289
- // Unload a skill to free context space (rewrites the tool result in history)
290
- await session.unloadSkill("skills", "api-reference");
291
-
292
- // Check what's currently loaded
293
- const loaded = await session.getLoadedSkillKeys(); // Set<"skills:api-reference">
189
+ ```ts
190
+ const branch = await session.getHistory({ leafId: alternativeReply.id });
191
+ const alternatives = await session.getBranches(userMessage.id);
294
192
  ```
295
193
 
296
- After hibernation/eviction, loaded skills are reconstructed by scanning conversation history for `load_context` tool results. This means skill state survives restarts without additional storage.
297
-
298
- > **Gotcha:** Skill restoration scans the entire conversation history looking for `load_context` tool invocations in assistant messages with `state: "output-available"`. When you unload a skill, it does not delete the tool result — it rewrites the `output` field to `"[skill unloaded: key]"` in-place. This means the original loaded content is permanently lost from history after unload.
299
-
300
- ---
301
-
302
- ## AI Tools
303
-
304
- Session automatically generates tools based on the provider types of your context blocks. Pass these to your LLM alongside your own tools.
194
+ To move a conversation between Durable Objects, export the source with `history()` and replay it with `importMessage()` on the destination:
305
195
 
306
- ```typescript
307
- const tools = await session.tools();
308
- // Merge with your own tools:
309
- const allTools = { ...tools, ...myTools };
196
+ ```ts
197
+ let parentId: string | null = null;
198
+ for await (const message of source.history()) {
199
+ await destination.importMessage(message, {
200
+ parentId,
201
+ createdAt: message.createdAt?.getTime() ?? Date.now()
202
+ });
203
+ parentId = message.id;
204
+ }
310
205
  ```
311
206
 
312
- ### `set_context`
313
-
314
- Generated when any writable block exists. Writes to regular blocks, skill blocks (keyed), or search blocks (keyed).
315
-
316
- - For regular blocks: `{ label, content, action: "replace" | "append" }`
317
- - For skill blocks: `{ label, content, metadata?: { title, description } }`
318
- - For search blocks: `{ label, content, metadata?: { title } }`
319
-
320
- Enforces `maxTokens` limits. Returns a usage string like `"Written to memory. Usage: 45% (495/1100 tokens)"`.
321
-
322
- For keyed blocks, `metadata.title` becomes the stable entry key. If title is omitted, the key is generated from the content plus a short deterministic hash to avoid silent collisions; provide a title when you want later writes to update the same entry.
323
-
324
- ### `load_context`
325
-
326
- Generated when any skill block exists. Loads full content by key from a `SkillProvider`.
327
-
328
- - Input: `{ label, key }`
329
- - Returns the document content, or `"Not found: key"`
330
-
331
- ### `unload_context`
332
-
333
- Generated alongside `load_context`. Frees context space by unloading a previously loaded skill.
207
+ `importMessage()` writes one historical message verbatim: explicit parent and timestamp, no change-feed event. It splits over-budget messages the same way an append does.
334
208
 
335
- - Input: `{ label, key }`
336
- - Rewrites the tool result in conversation history to a short marker
337
- - The skill remains available for re-loading
338
-
339
- The tool's description dynamically lists currently loaded skills.
340
-
341
- ### `search_context`
342
-
343
- Generated when any search block exists. Full-text search within a searchable context block.
344
-
345
- - Input: `{ label, query }`
346
- - Returns top 10 results by FTS5 rank, or `"No results found."`
347
-
348
- ### `session_search`
349
-
350
- Available on `SessionManager` only (not on individual sessions). Searches across all sessions.
351
-
352
- - Input: `{ query }`
353
- - Returns results from all sessions, or `"No results found."`
354
-
355
- Use `{ ...sessionTools, ...manager.tools() }` to give the model both per-session and cross-session tools.
356
-
357
- ---
209
+ For one-Durable-Object-per-conversation applications, keep the conversation directory in a parent or router Durable Object.
358
210
 
359
211
  ## Compaction
360
212
 
361
- Compaction summarizes older messages to keep conversations within token limits. Original messages are preserved in the underlying message store — the summary is a non-destructive overlay applied at read time.
213
+ Compaction overlays replace a range at read time without deleting the original rows:
362
214
 
363
- ### Setup
215
+ ```ts
216
+ import { createCompactFunction } from "agents/sessions";
364
217
 
365
- ```typescript
366
- import { createCompactFunction } from "agents/experimental/memory/utils/compaction-helpers";
367
-
368
- const session = Session.create(this)
369
- .withContext("memory", { maxTokens: 1100 })
218
+ session
370
219
  .onCompaction(
371
220
  createCompactFunction({
372
- summarize: (prompt) =>
373
- generateText({ model: myModel, prompt }).then((r) => r.text),
374
- protectHead: 3, // Keep first 3 messages (default: 3)
375
- tailTokenBudget: 20000, // Protect ~20K tokens at the tail (default: 20000)
376
- minTailMessages: 2, // Always keep at least 2 tail messages (default: 2)
377
- tokenCounter: async (messages) => estimateWithYourTokenizer({ messages })
221
+ summarize: async (prompt) => summarize(prompt),
222
+ keepRecentTokens: 20_000
378
223
  })
379
224
  )
380
- .compactAfter(100_000); // Auto-compact at 100K estimated tokens
225
+ .compactAfter(80_000);
381
226
  ```
382
227
 
383
- ### How It Works
384
-
385
- 1. **Protect head** — first N messages are never compacted (default 3)
386
- 2. **Protect tail** — walk backward from the end, accumulating tokens up to a budget (default 20K tokens)
387
- 3. **Align boundaries** — shift boundaries to avoid splitting tool call/result pairs
388
- 4. **Summarize middle** — send the middle section to an LLM with a structured format (Topic, Key Points, Current State, Open Items)
389
- 5. **Store overlay** — saved in `assistant_compactions` table, keyed by `fromMessageId` and `toMessageId`
390
- 6. **Iterative** — on subsequent compactions, the existing summary is passed to the LLM to update rather than replace
228
+ `createCompactFunction` takes exactly two options. `summarize` calls the model with a prompt and returns its text. `keepRecentTokens` is the token budget for the recent tail kept verbatim, defaulting to 20,000. The first three messages are kept verbatim as the head, at least the last two are kept as the tail, and the boundaries are aligned so a tool call is never separated from its result.
391
229
 
392
- When `getHistory()` is called, compaction overlays are applied transparently — the compacted range is replaced by a synthetic message with id `compaction_<id>`.
230
+ Run it explicitly:
393
231
 
394
- ### Manual Compaction
395
-
396
- ```typescript
397
- // Run registered compaction function
398
- const result = await session.compact();
399
-
400
- // Or manage overlays directly
401
- await session.addCompaction("Summary of messages 1-50", "msg-1", "msg-50");
402
- const overlays = await session.getCompactions();
232
+ ```ts
233
+ await session.compact();
234
+ await session.compact(leafId); // compact a specific branch
403
235
  ```
404
236
 
405
- ### Auto-Compaction
406
-
407
- When `.compactAfter(threshold)` is set, `appendMessage()` checks the estimated token count after each write. If it exceeds the threshold, `compact()` is called automatically. Auto-compaction failure is non-fatal — the message is already saved.
408
-
409
- > Auto-compaction is checked **between turns** (on each `appendMessage()`), not within a turn. A single long, tool-heavy turn can grow past the model's context window mid-flight, before the next check. `@cloudflare/think` adds opt-in mid-turn recovery on top of this — see [Context-window overflow recovery](https://github.com/cloudflare/agents/blob/main/docs/think/index.md#context-window-overflow-recovery).
410
-
411
- By default, the estimate includes stored message parts plus the Session-managed frozen system prompt. That means context blocks and cached prompts managed by `Session` contribute to the threshold. The estimate does not include framework-specific prompt additions or tool schema serialization that happen outside `Session`, such as Think's final capability prompt and tool catalog.
412
-
413
- There are two token-counting decisions:
237
+ Or store an already-produced summary:
414
238
 
415
- - `.compactAfter(threshold, { tokenCounter })` controls when automatic compaction is triggered after writes. It can include the frozen system prompt and context blocks.
416
- - `createCompactFunction({ tokenCounter })` controls which tail messages are protected from summarization. Use this when tool-heavy histories are much larger than the Workers-safe heuristic can estimate.
417
-
418
- You usually only need to configure one counter. The `.compactAfter()` counter now also flows into `createCompactFunction`'s boundary walk (via `CompactContext`) when no explicit `createCompactFunction({ tokenCounter })` is given, so a single counter drives both "should we compact?" and "what should we compact?". Without this, a fire-only counter plus the under-counting heuristic could make compaction trigger every turn but silently no-op on tool-heavy histories (fixed in [#1593](https://github.com/cloudflare/agents/issues/1593)).
419
-
420
- Caveat: the flowed counter is invoked **per message** during the boundary walk. A tokenizer-style counter budgets accurately; a usage-only counter that returns a fixed whole-prompt total (e.g. `usage.inputTokens` regardless of which messages are passed) degrades the tail budget to `minTailMessages` — compaction still runs and context stays bounded, but the byte budget is effectively ignored. Pass an explicit per-message `createCompactFunction({ tokenCounter })` for precise tail budgeting.
421
-
422
- Use a custom counter when you have model-reported usage or your own tokenizer:
423
-
424
- ```typescript
425
- const session = Session.create(this)
426
- .onCompaction(myCompactFn)
427
- .compactAfter(100_000, {
428
- tokenCounter: async ({ messages, systemPrompt, contextBlocks }) => {
429
- return estimateWithYourTokenizer({
430
- messages,
431
- systemPrompt,
432
- contextBlocks
433
- });
434
- }
435
- })
436
- .onCompactionError((err) => {
437
- console.warn("Auto-compaction failed", err);
438
- });
239
+ ```ts
240
+ await session.addCompaction(summary, fromMessageId, toMessageId);
439
241
  ```
440
242
 
441
- > **Note:** The default token estimation is heuristic (not tiktoken). It uses `max(chars/4, words*1.3)` with 4 tokens per-message overhead, and also applies the string heuristic to the Session-managed system prompt. This is intentional — tiktoken would add 80-120MB heap overhead, which exceeds Cloudflare Workers' 128MB limit.
243
+ Sessions stamps each message with a token estimate when the row is written. `compactAfter()` gates on that O(1) aggregate and never reads the transcript to decide whether to compact. Auto-compaction failures are non-fatal: they log, emit `session:error`, and leave the transcript alone.
442
244
 
443
- > **Gotcha:** Compaction is iterative but single-overlay. Each new compaction extends from the earliest existing compaction's `fromMessageId` to the new end. So you always have at most one active compaction overlay per session, and it keeps growing. The previous compaction rows remain in the database but are superseded by the latest one (which covers a wider range). `getCompactions()` returns all of them, but `getHistory()` applies the latest one.
245
+ To trim a transcript before handing it to a model, `truncateOlderMessages` is exported from [`agents/chat`](./chat-agents.md), not from `agents/sessions`.
444
246
 
445
- ---
247
+ ## Large messages
446
248
 
447
- ## SessionManager
249
+ A message is stored as one JSON string. When that string exceeds the row budget it is cut into slices: slice 0 lives in the message row, the rest become numbered continuation rows in `cf_agents_session_message_chunks`. A read concatenates them back, so what you append is exactly what you read.
448
250
 
449
- `SessionManager` is a registry for multiple named sessions within a single Durable Object. It provides lifecycle management, convenience methods, and cross-session search.
251
+ This is invisible from the outside. There is no pointer, no reconstruction mode, and no read option:
450
252
 
451
- ### Creating a SessionManager
452
-
453
- ```typescript
454
- import { SessionManager } from "agents/experimental/memory/session";
455
-
456
- const manager = SessionManager.create(this)
457
- .withContext("soul", { provider: { get: async () => "You are helpful." } })
458
- .withContext("memory", { description: "Learned facts", maxTokens: 1100 })
459
- .withCachedPrompt()
460
- .onCompaction(myCompactFn)
461
- .compactAfter(100_000)
462
- .withSearchableHistory("history");
463
- ```
464
-
465
- Context blocks, prompt caching, and compaction settings are propagated to all sessions created through the manager. Provider keys are automatically namespaced by session ID (e.g. `memory_<sessionId>`).
466
-
467
- ### Builder Methods
468
-
469
- | Method | Description |
470
- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
471
- | `SessionManager.create(agent)` | Static factory. |
472
- | `.withContext(label, options?)` | Add context block template for all sessions. |
473
- | `.withCachedPrompt(provider?)` | Enable prompt persistence for all sessions. |
474
- | `.onCompaction(fn)` | Register compaction function for all sessions. |
475
- | `.compactAfter(tokenThreshold, options?)` | Auto-compact threshold for all sessions. Supports the same `tokenCounter` option as `Session`. |
476
- | `.onCompactionError(handler)` | Handle automatic compaction errors for managed sessions. |
477
- | `.withSearchableHistory(label)` | Add a cross-session searchable history block to every session. The model can search past conversations from any session. |
478
-
479
- ### Session Lifecycle
480
-
481
- ```typescript
482
- // Create a new session
483
- const info = manager.create("My Chat");
484
- // info: { id, name, parent_session_id, model, source, input_tokens, output_tokens, estimated_cost, end_reason, created_at, updated_at }
485
-
486
- // Create with metadata
487
- const info2 = manager.create("My Chat", {
488
- parentSessionId: "parent-id",
489
- model: "claude-sonnet-4-20250514",
490
- source: "web"
491
- });
492
-
493
- // Get session metadata (null if not found)
494
- const session = manager.get(sessionId);
495
-
496
- // List all sessions (ordered by updated_at DESC)
497
- const sessions = manager.list();
498
-
499
- // Rename
500
- manager.rename(sessionId, "New Name");
501
-
502
- // Delete (clears messages too)
503
- await manager.delete(sessionId);
504
- ```
505
-
506
- ### Accessing Sessions
507
-
508
- ```typescript
509
- // Get or create the Session instance for an ID
510
- // Lazy — creates on first access, caches for subsequent calls
511
- const session = manager.getSession(sessionId);
253
+ ```ts
254
+ await session.appendMessage(message); // any size
255
+ const stored = await session.getMessage(message.id); // byte-identical
512
256
  ```
513
257
 
514
- ### Message Convenience Methods
258
+ The common path pays nothing for it. A window of messages with no continuations issues exactly the same queries it always did; the extra query for continuation rows runs only for the ids in the window that actually have them.
515
259
 
516
- These delegate to the underlying Session but also update the session's `updated_at` timestamp:
260
+ ### What it costs
517
261
 
518
- ```typescript
519
- // Append a single message
520
- await manager.append(sessionId, message, parentId?);
262
+ Splitting is not a way to shrink the database. Continuation rows live in the same Durable Object as the message they belong to, inside the same 10 GB. Billing counts rows written, not bytes, so a 500 KB message costs the same one billed row as a tiny one, and a 2 MB message costs two.
521
263
 
522
- // Add or update (upsert)
523
- await manager.upsert(sessionId, message, parentId?);
264
+ `SessionRowStat.bytes` and the `totalContentBytes` returned by `getRecentHistory()` report the whole message — row plus continuations — so a byte budget passed to `getRecentHistory()` bounds the memory a hydration will actually take.
524
265
 
525
- // Batch append (auto-chains parent IDs)
526
- await manager.appendAll(sessionId, messages, parentId?);
266
+ ### Files belong in a file store
527
267
 
528
- // Read history
529
- const history = await manager.getHistory(sessionId, leafId?);
268
+ Sessions is a message store, not a file store. Attaching a file is convenient and exact, but it is stored in the conversation forever and counted against the Durable Object's 10 GB. An application that handles files should write them to R2 or the Workspace and put a path or URL in the message instead.
530
269
 
531
- // Message count
532
- const count = await manager.getMessageCount(sessionId);
270
+ ## Attachments
533
271
 
534
- // Clear messages
535
- await manager.clearMessages(sessionId);
272
+ A part that declares a non-text media type and carries its bytes inline — an image, an audio clip, a PDF — is stored outside the message. The part keeps its shape and its `mediaType`; only the payload is replaced, by an `attachment:sha256:<hex>` pointer. Reads put the payload back, so this is invisible from the outside:
536
273
 
537
- // Delete specific messages
538
- await manager.deleteMessages(sessionId, ["msg-1"]);
274
+ ```ts
275
+ await session.appendMessage(messageWithImage);
276
+ const stored = await session.getMessage(messageWithImage.id); // byte-identical
539
277
  ```
540
278
 
541
- ### Forking
542
-
543
- Fork a session at a specific message — copies history up to that point into a new session:
544
-
545
- ```typescript
546
- const forked = await manager.fork(sessionId, atMessageId, "Forked Chat");
547
- // forked.parent_session_id === sessionId
548
- ```
549
-
550
- > **Gotcha:** Fork copies messages with new UUIDs, not the original IDs. This means message IDs in the forked session will not match the original. The fork also does not copy compaction overlays — the forked session starts clean with the materialized history.
551
-
552
- ### Compaction
279
+ The rule is about **type, not size**. An image is stored this way whether it is 8 KB or 8 MB, and text is never stored this way at any size — long prose is split across continuation rows instead. The two mechanisms never interact: media leaves the message before the row is measured, so a message carrying a large image usually has no continuation rows at all.
553
280
 
554
- ```typescript
555
- // Add a compaction overlay
556
- await manager.addCompaction(sessionId, summary, fromId, toId);
281
+ Identical payloads are stored once. That is a consequence of addressing bytes by their hash, which mainly means a retried write costs nothing; do not rely on it as a space optimization.
557
282
 
558
- // Get overlays
559
- const compactions = await manager.getCompactions(sessionId);
283
+ A payload lives as long as some message references it. Deleting the last message that points at one deletes the bytes; clearing a session deletes all of them.
560
284
 
561
- // Compact and split — marks old session as ended, creates a continuation
562
- const continuation = await manager.compactAndSplit(
563
- sessionId,
564
- summary,
565
- "Continued Chat"
566
- );
567
- // continuation.parent_session_id === sessionId
568
- // Old session gets end_reason = "compaction"
569
- ```
570
-
571
- `compactAndSplit` is different from regular compaction — it creates a new session with a summary message instead of an in-place overlay. The original session is marked with `end_reason: "compaction"`.
285
+ ### What it costs
572
286
 
573
- ### Usage Tracking
287
+ Keeping media out of the message is not free. A 200 KB image bills four row writes — the message, one payload chunk, its metadata, and one reference — where inlining it would bill one. A 2 MiB image bills five. Text messages are unaffected and still bill exactly one row.
574
288
 
575
- ```typescript
576
- manager.addUsage(sessionId, inputTokens, outputTokens, cost);
577
- // Increments input_tokens, output_tokens, and estimated_cost on the session row
578
- ```
289
+ What you get is a message row that stays a few hundred bytes however large the payload is. `SessionRowStat.bytes` still charges each message for the payloads it points at, so the byte budget you pass to `getRecentHistory()` remains a bound on the memory a hydration actually takes.
579
290
 
580
- ### Cross-Session Search
291
+ That budget is a hard ceiling with no message-count floor beneath it. `getRecentHistory()` returns the longest recent suffix that fits, and always at least the newest message; a window of unusually large messages is simply shorter. A floor that admitted rows regardless of size would defeat the bound it sits under, which is why there is no longer a `minRecentMessages` argument.
581
292
 
582
- ```typescript
583
- // Search across all sessions (FTS5)
584
- const results = manager.search("deployment Friday", { limit: 20 });
585
- // Returns: Array<{ id, role, content, createdAt }>
293
+ ## Full-text search
586
294
 
587
- // Get tools for the model (includes session_search)
588
- const tools = manager.tools();
295
+ ```ts
296
+ const results = await session.search("deployment failed");
589
297
  ```
590
298
 
591
- > **Note:** `manager.search()` uses a separate FTS5 index (`assistant_fts`) from per-session search. Messages are indexed into this table by the `AgentSessionProvider` when appended. The `session_search` tool limits results to 10.
592
-
593
- > **Gotcha:** `manager.search()` silently returns an empty array on FTS5 query errors (malformed queries, etc.) rather than throwing.
594
-
595
- ---
596
-
597
- ## Storage
598
-
599
- By default, storage is in Durable Object SQLite and tables are created lazily on first use. Postgres-backed sessions use the external tables shown in the Postgres section below.
600
-
601
- ### Tables
602
-
603
- **`assistant_messages`** — Tree-structured messages.
604
-
605
- | Column | Type | Notes |
606
- | ------------ | -------- | ------------------------------------------------------ |
607
- | `id` | TEXT | Message ID |
608
- | `session_id` | TEXT | Empty string for single-session; set for multi-session |
609
- | `parent_id` | TEXT | Parent message ID (null for roots) |
610
- | `role` | TEXT | `user`, `assistant`, `system` |
611
- | `content` | TEXT | JSON-serialized `SessionMessage` |
612
- | `created_at` | DATETIME | Auto-set |
613
-
614
- For Postgres, messages use `PRIMARY KEY (session_id, id)` so caller-provided IDs only need to be unique within a session.
299
+ The index is built by the first `search()` call on an object, with a one-time SQL-only backfill of the messages already stored, and maintained on every write from then on. An object that never searches never pays for an index: maintaining one costs an extra billed row on every append, so it exists only where something actually reads it.
615
300
 
616
- **`assistant_compactions`** Compaction overlays.
301
+ Search indexes text parts only. File contents, reasoning, and tool payloads are not indexed automatically.
617
302
 
618
- | Column | Type | Notes |
619
- | ----------------- | -------- | ------------------------ |
620
- | `id` | TEXT PK | Random UUID |
621
- | `session_id` | TEXT | Scoped to session |
622
- | `summary` | TEXT | LLM-generated summary |
623
- | `from_message_id` | TEXT | Start of compacted range |
624
- | `to_message_id` | TEXT | End of compacted range |
625
- | `created_at` | DATETIME | Auto-set |
303
+ ## Storage economics
626
304
 
627
- **`assistant_fts`** FTS5 virtual table for message search. Tokenizer: `porter unicode61`.
305
+ On Durable Object SQLite a row write costs roughly 1000 times a row read, so the schema is built to keep one logical write to one billed row.
628
306
 
629
- **`assistant_sessions`** Session registry (SessionManager only).
307
+ Every Sessions table is `WITHOUT ROWID` with a composite primary key and no secondary index:
630
308
 
631
- | Column | Type | Notes |
632
- | ------------------- | -------- | -------------------------- |
633
- | `id` | TEXT PK | Random UUID |
634
- | `name` | TEXT | Display name |
635
- | `parent_session_id` | TEXT | For forks/splits |
636
- | `model` | TEXT | Optional model identifier |
637
- | `source` | TEXT | Optional source identifier |
638
- | `input_tokens` | INTEGER | Cumulative input tokens |
639
- | `output_tokens` | INTEGER | Cumulative output tokens |
640
- | `estimated_cost` | REAL | Cumulative cost |
641
- | `end_reason` | TEXT | `"compaction"` when split |
642
- | `created_at` | DATETIME | Auto-set |
643
- | `updated_at` | DATETIME | Updated on message ops |
309
+ | Table | Primary key | Contents |
310
+ | ---------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------- |
311
+ | `cf_agents_session_messages` | `(session_id, id)` | `seq`, `parent_id`, `type`, `role`, JSON content, continuation count, token estimate, timestamp |
312
+ | `cf_agents_session_message_chunks` | `(session_id, id, idx)` | Continuation slices of a message too large for one row |
313
+ | `cf_agents_session_compactions` | `(session_id, id)` | Non-destructive summary ranges |
314
+ | `cf_agents_session_config` | `(session_id, key)` | Lifted session configuration |
315
+ | `cf_agents_session_fts` | virtual | FTS5 index, created by the first `search()` |
644
316
 
645
- **`cf_agents_context_blocks`** Persistent context block storage (`AgentContextProvider`).
646
-
647
- **`cf_agents_search_entries`** + **`cf_agents_search_fts`** — Searchable context entries and FTS5 index (`AgentSearchProvider`).
648
-
649
- ---
650
-
651
- ## Custom Providers
652
-
653
- You can implement any of the four provider interfaces to plug in your own storage:
654
-
655
- ```typescript
656
- // Read-only context
657
- const myProvider: ContextProvider = {
658
- get: async () => "Static content here"
659
- };
660
-
661
- // Writable context (enables set_context tool)
662
- const myWritable: WritableContextProvider = {
663
- get: async () => fetchFromMyDB(),
664
- set: async (content) => saveToMyDB(content)
665
- };
666
-
667
- // Skill provider (enables load_context tool)
668
- const mySkills: SkillProvider = {
669
- get: async () => "- api-ref: API Reference\n- guide: User Guide",
670
- load: async (key) => fetchDocument(key),
671
- set: async (key, content, description) =>
672
- saveDocument(key, content, description) // optional
673
- };
674
-
675
- // Search provider (enables search_context tool)
676
- const mySearch: SearchProvider = {
677
- get: async () => "42 entries indexed",
678
- search: async (query) => searchMyIndex(query),
679
- set: async (key, content) => indexContent(key, content) // optional
680
- };
681
- ```
682
-
683
- You can also implement `SessionProvider` to replace the SQLite storage entirely:
684
-
685
- ```typescript
686
- const myStorage: SessionProvider = {
687
- getMessage(id) { ... },
688
- getHistory(leafId?) { ... },
689
- getLatestLeaf() { ... },
690
- getBranches(messageId) { ... },
691
- getPathLength(leafId?) { ... },
692
- appendMessage(message, parentId?) { ... },
693
- updateMessage(message) { ... },
694
- deleteMessages(messageIds) { ... },
695
- clearMessages() { ... },
696
- addCompaction(summary, fromId, toId) { ... },
697
- getCompactions() { ... },
698
- searchMessages(query, limit) { ... } // optional
699
- };
700
- ```
317
+ A text append bills exactly one row write on an object that has never searched. The `seq` column carries ordering and `type` distinguishes row kinds, so neither needs an index. A continuation row carries its slice and nothing else — no hash, no media type, no size — so an over-budget message costs one billed row per slice and nothing more.
701
318
 
702
- ---
319
+ State is derived rather than maintained. The active leaf is the session's max-`seq` row and token totals come from per-row stamped estimates. No counter row, registry row, or refcount is written on the append path.
703
320
 
704
- ## Postgres (External Database)
321
+ ## Observe changes
705
322
 
706
- The default 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`, `PostgresContextProvider`, and `PostgresSearchProvider`.
323
+ `Sessions.subscribe()` reports ordered writes in the active isolate:
707
324
 
708
- These work with any Postgres-compatible database (Neon, Supabase, PlanetScale, etc.) via [Cloudflare Hyperdrive](https://developers.cloudflare.com/hyperdrive/) for connection pooling.
709
-
710
- ### Setup
711
-
712
- #### 1. Create a Postgres database
713
-
714
- Use any Postgres provider and copy the connection string.
715
-
716
- #### 2. Create a Hyperdrive config
717
-
718
- ```bash
719
- npx wrangler hyperdrive create my-session-db \
720
- --connection-string="postgresql://user:password@host:port/dbname"
721
- ```
722
-
723
- Copy the returned Hyperdrive ID.
724
-
725
- #### 3. Create the tables
726
-
727
- The Postgres user might not have `CREATE TABLE` permissions. Run this once in your database console:
728
-
729
- ```sql
730
- CREATE TABLE IF NOT EXISTS assistant_messages (
731
- id TEXT NOT NULL,
732
- session_id TEXT NOT NULL DEFAULT '',
733
- parent_id TEXT,
734
- role TEXT NOT NULL,
735
- content TEXT NOT NULL,
736
- text_content TEXT NOT NULL DEFAULT '',
737
- created_at TIMESTAMPTZ DEFAULT NOW(),
738
- content_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', text_content)) STORED,
739
- PRIMARY KEY (session_id, id)
740
- );
741
- CREATE INDEX IF NOT EXISTS idx_assistant_msg_parent ON assistant_messages (parent_id);
742
- CREATE INDEX IF NOT EXISTS idx_assistant_msg_session ON assistant_messages (session_id);
743
- CREATE INDEX IF NOT EXISTS idx_assistant_msg_fts ON assistant_messages USING GIN (content_tsv);
744
-
745
- CREATE TABLE IF NOT EXISTS assistant_compactions (
746
- id TEXT PRIMARY KEY,
747
- session_id TEXT NOT NULL DEFAULT '',
748
- summary TEXT NOT NULL,
749
- from_message_id TEXT NOT NULL,
750
- to_message_id TEXT NOT NULL,
751
- created_at TIMESTAMPTZ DEFAULT NOW()
752
- );
753
-
754
- CREATE TABLE IF NOT EXISTS cf_agents_context_blocks (
755
- label TEXT PRIMARY KEY,
756
- content TEXT NOT NULL,
757
- updated_at TIMESTAMPTZ DEFAULT NOW()
758
- );
759
-
760
- CREATE TABLE IF NOT EXISTS cf_agents_search_entries (
761
- label TEXT NOT NULL,
762
- key TEXT NOT NULL,
763
- content TEXT NOT NULL,
764
- content_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
765
- created_at TIMESTAMPTZ DEFAULT NOW(),
766
- updated_at TIMESTAMPTZ DEFAULT NOW(),
767
- PRIMARY KEY (label, key)
768
- );
769
- CREATE INDEX IF NOT EXISTS idx_search_entries_fts ON cf_agents_search_entries USING GIN (content_tsv);
770
- ```
771
-
772
- #### 4. Configure wrangler
773
-
774
- ```jsonc
775
- {
776
- "compatibility_flags": ["nodejs_compat"],
777
- "hyperdrive": [
778
- {
779
- "binding": "HYPERDRIVE",
780
- "id": "<your-hyperdrive-id>"
781
- }
782
- ],
783
- "placement": {
784
- "region": "aws:us-east-1" // match your database region
785
- }
786
- }
787
- ```
788
-
789
- #### 5. Wire it up
790
-
791
- ```typescript
792
- import { Agent, callable } from "agents";
793
- import {
794
- Session,
795
- PostgresSessionProvider,
796
- PostgresContextProvider,
797
- PostgresSearchProvider
798
- } from "agents/experimental/memory/session";
799
- import { Client } from "pg";
800
-
801
- class MyAgent extends Agent<Env> {
802
- private _session?: Session;
803
- private _pgClient?: Client;
804
-
805
- /**
806
- * Initialize Hyperdrive and Session when the Durable Object starts.
807
- * The providers take the raw pg.Client directly — no wrapper needed.
808
- */
809
- async onStart(): Promise<void> {
810
- const client = new Client({
811
- connectionString: this.env.HYPERDRIVE.connectionString
812
- });
813
- await client.connect();
814
- this._pgClient = client;
815
-
816
- const sessionId = this.ctx.id.toString();
817
- this._session = Session.create(
818
- new PostgresSessionProvider(client, sessionId)
819
- )
820
- .withContext("soul", {
821
- provider: {
822
- get: async () => "You are a helpful assistant."
823
- }
824
- })
825
- .withContext("memory", {
826
- description: "Short facts",
827
- maxTokens: 1100,
828
- provider: new PostgresContextProvider(client, `memory_${sessionId}`)
829
- })
830
- .withContext("knowledge", {
831
- description: "Searchable knowledge base",
832
- provider: new PostgresSearchProvider(client)
833
- })
834
- .withCachedPrompt(
835
- new PostgresContextProvider(client, `_prompt_${sessionId}`)
836
- );
325
+ ```ts
326
+ const unsubscribe = sessions.subscribe(async (event) => {
327
+ if (event.sessionId !== "") return;
328
+ switch (event.type) {
329
+ case "append":
330
+ case "update":
331
+ await updateLocalProjection(event.message);
332
+ break;
333
+ case "delete":
334
+ case "clear":
335
+ case "compact":
336
+ case "compaction":
337
+ await refreshProjection();
338
+ break;
339
+ case "import":
340
+ markProjectionStale();
341
+ break;
837
342
  }
838
- }
839
- ```
840
-
841
- ### How it works
842
-
843
- When `Session.create()` receives a `SessionProvider` instead of a `SqlProvider`, it skips all SQLite auto-wiring. This means:
844
-
845
- - **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.
846
- - **`withCachedPrompt()` needs an explicit provider.** Pass a `PostgresContextProvider` to persist the frozen system prompt.
847
- - **Broadcaster is skipped.** WebSocket status broadcasts (`CF_AGENT_SESSION` events) only work with `SqlProvider`-based sessions.
848
- - **All Session methods are async.** `getHistory()`, `getMessage()`, etc. return Promises since the underlying storage is async.
849
-
850
- ### System prompt lifecycle
851
-
852
- - **`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.
853
- - **`refreshSystemPrompt()`** — force reloads blocks from providers, re-renders, and updates the store. Call this to invalidate the cached prompt (e.g. after `clearMessages`).
854
-
855
- ### Connection types
856
-
857
- The Postgres providers accept either of:
858
-
859
- - A raw `pg.Client` (or any object with a compatible `query(text, values)` method) — the recommended path for Hyperdrive.
860
- - Any object implementing `PostgresConnection` — useful for tests or custom drivers.
861
-
862
- ```typescript
863
- // For tests or custom drivers
864
- interface PostgresConnection {
865
- execute(
866
- query: string,
867
- args?: (string | number | boolean | null)[]
868
- ): Promise<{ rows: Record<string, unknown>[] }>;
869
- }
343
+ });
870
344
  ```
871
345
 
872
- Internally the providers use `?` placeholders; when a `pg`-style client is passed, those are rewritten to `$1, $2, …` automatically.
873
-
874
- ### Search
875
-
876
- Two levels of search are available:
346
+ `import` fires once per `importMessage()` and carries the row; it is deliberately not an `append`, so a projection does not patch itself per imported row during a migration — it marks itself stale and re-derives once. `compaction` fires when an overlay is stored directly through `addCompaction()`; `compact()` reports its own overlay as `compact`.
877
347
 
878
- - **Message search** `PostgresSessionProvider.searchMessages()` searches conversation history via the `content_tsv` column on `assistant_messages`.
879
- - **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.
348
+ This is a local cache-coherence feed, not a cross-object event log. Capability diagnostics are also emitted through Lifecycle observability.
880
349
 
881
- The migration SQL above includes both tables with tsvector columns and GIN indexes — search works out of the box.
350
+ ## Errors
882
351
 
883
- ---
352
+ Sessions defines no error classes. A message that is not JSON-serializable fails with the `TypeError` that `JSON.stringify` throws, and there is no "message too large" error. A message that exceeds the row budget is split across continuation rows instead.
884
353
 
885
- ## Utilities
354
+ ## Multiple sessions
886
355
 
887
- Exported from `agents/experimental/memory/utils`:
356
+ Multiple handles can share one Durable Object:
888
357
 
889
- ### Token Estimation
890
-
891
- ```typescript
892
- import {
893
- estimateStringTokens,
894
- estimateMessageTokens
895
- } from "agents/experimental/memory/utils/tokens";
896
-
897
- estimateStringTokens("Hello world"); // heuristic: max(chars/4, words*1.3)
898
- estimateMessageTokens(messages); // sum with 4 tokens per-message overhead
358
+ ```ts
359
+ const support = sessions.session("support");
360
+ const sales = sessions.session("sales");
899
361
  ```
900
362
 
901
- ### Compaction Helpers
902
-
903
- ```typescript
904
- import {
905
- createCompactFunction,
906
- isCompactionMessage,
907
- sanitizeToolPairs,
908
- alignBoundaryForward,
909
- alignBoundaryBackward,
910
- findTailCutByTokens,
911
- computeSummaryBudget,
912
- buildSummaryPrompt,
913
- COMPACTION_PREFIX
914
- } from "agents/experimental/memory/utils/compaction-helpers";
915
- ```
916
-
917
- - `createCompactFunction(options)` — Full compaction implementation. See [Compaction](#compaction).
918
- - `isCompactionMessage(msg)` — Check if a message is a compaction overlay (id starts with `compaction_`).
919
- - `sanitizeToolPairs(messages)` — Fix orphaned tool call/result pairs after compaction. Removes orphaned results and adds stub results for calls whose results were dropped.
920
- - `alignBoundaryForward/Backward(messages, idx)` — Shift a boundary index to avoid splitting tool call/result groups.
921
- - `findTailCutByTokens(messages, headEnd, budget, minMessages)` — Find where to stop compressing using a token budget.
922
- - `computeSummaryBudget(messages)` — 20% of compressed content tokens (minimum 100).
923
- - `buildSummaryPrompt(messages, previousSummary, budget)` — Structured prompt for LLM summarization.
924
-
925
- ---
926
-
927
- ## Exports
928
-
929
- Everything is exported from `agents/experimental/memory/session`:
930
-
931
- ```typescript
932
- import {
933
- // Core
934
- Session,
935
- SessionManager,
936
-
937
- // Providers
938
- AgentSessionProvider,
939
- AgentContextProvider,
940
- AgentSearchProvider,
941
- R2SkillProvider,
942
- PostgresSessionProvider,
943
- PostgresContextProvider,
944
- PostgresSearchProvider,
945
-
946
- // Type guards
947
- isWritableProvider,
948
- isSkillProvider,
949
- isSearchProvider,
950
-
951
- // Types
952
- type SessionMessage,
953
- type SessionMessagePart,
954
- type SessionContextOptions,
955
- type SessionInfo,
956
- type SessionManagerOptions,
957
- type SessionOptions,
958
- type ContextBlock,
959
- type ContextConfig,
960
- type ContextProvider,
961
- type WritableContextProvider,
962
- type SkillProvider,
963
- type SearchProvider,
964
- type SearchResult,
965
- type SessionProvider,
966
- type StoredCompaction,
967
- type SqlProvider,
968
- type PostgresConnection
969
- } from "agents/experimental/memory/session";
970
- ```
971
-
972
- Compaction utilities from `agents/experimental/memory/utils/compaction-helpers`:
973
-
974
- ```typescript
975
- import {
976
- createCompactFunction,
977
- isCompactionMessage,
978
- sanitizeToolPairs,
979
- COMPACTION_PREFIX,
980
- type CompactResult,
981
- type CompactOptions
982
- } from "agents/experimental/memory/utils/compaction-helpers";
983
- ```
984
-
985
- Token utilities from `agents/experimental/memory/utils/tokens`:
986
-
987
- ```typescript
988
- import {
989
- estimateStringTokens,
990
- estimateMessageTokens
991
- } from "agents/experimental/memory/utils/tokens";
992
- ```
993
-
994
- ---
995
-
996
- ## Gotchas and Quirks
997
-
998
- Things that might surprise you:
999
-
1000
- 1. **Lazy initialization.** Sessions created with the builder do not initialize until first use. The first call to any method (e.g. `getHistory()`) triggers `_ensureReady()`, which creates SQLite tables or initializes the configured provider, resolves providers, loads context blocks, and restores skill state from history. This means the first operation is slower than subsequent ones.
1001
-
1002
- 2. **Snapshot freezing is sticky.** `freezeSystemPrompt()` caches the result. Writing to a context block does NOT update the cached snapshot — you must explicitly call `refreshSystemPrompt()`. This is deliberate (LLM prefix cache optimization), but easy to miss.
363
+ Handles are cached, so per-session configuration such as the compaction trigger survives repeated `session()` calls. There is no registry table: a directory of conversations belongs to the application.
1003
364
 
1004
- 3. **Session methods are async.** Always `await` reads and writes. SQLite-backed storage is local and fast, but external providers may perform network I/O, and `appendMessage` can trigger auto-compaction.
365
+ For user-facing chat applications, prefer one Durable Object per conversation. This isolates storage and failure domains and allows conversations to hibernate independently. Use multiple handles inside one object for local branches, drafts, or application-specific namespaces.
1005
366
 
1006
- 4. **Skills survive hibernation via history scanning.** On initialization, the session scans the entire conversation history looking for `load_context` tool results to reconstruct which skills are loaded. This is clever but means initialization cost scales with conversation length.
367
+ ## Chat hosts
1007
368
 
1008
- 5. **Compaction overlays are superseding, not stacking.** Each compaction extends from the earliest existing `fromMessageId`. So you always have one effective overlay that keeps growing. Old compaction rows remain in the database but are unused. `getCompactions()` returns all rows, which can be confusing.
369
+ `Think` and `AIChatAgent` use Sessions internally.
1009
370
 
1010
- 6. **Search is silently absent.** `session.search()` throws if the provider does not support search, but `manager.search()` swallows FTS5 errors and returns `[]`. The `searchMessages` method on `SessionProvider` is optional (`searchMessages?`).
371
+ - Think uses branches, compaction, search, and the change feed. Its prompt context comes from [`agents/context`](./context.md). Aged media leaves the conversation through Think's own media eviction, which is a context-window technique and unrelated to how Sessions stores a row.
372
+ - AIChatAgent uses the default handle as a linear chain. Its existing destructive regeneration, mutable `messages` array, retention option, and wire protocol remain unchanged.
1011
373
 
1012
- 7. **Fork copies with new IDs.** When forking via `SessionManager.fork()`, all messages get new UUIDs. If you are storing message IDs externally (e.g. for bookmarks), they will not survive a fork.
374
+ Existing `assistant_*` Session tables and `cf_ai_chat_agent_messages` are lifted automatically, then dropped once every row is verified copied.
1013
375
 
1014
- 8. **`removeContext` does not fire skill unload callbacks.** If you remove a context block that had loaded skills, the skill tracking is cleaned up but the conversation history is NOT rewritten. The tool results from those skills remain in history with their full content.
376
+ ## Memory boundaries and future streaming
1015
377
 
1016
- 9. **FTS5 query sanitization.** Both `AgentSearchProvider.search()` and `SessionManager.search()` quote individual words to prevent FTS5 syntax injection. This means you cannot use FTS5 operators like `OR`, `NOT`, or `NEAR` — they will be treated as literal search terms.
378
+ The capability streams history, but some consumers still require arrays.
1017
379
 
1018
- 10. **Auto-compaction failure is silent.** When `compactAfter` triggers and the compaction function throws, the error is emitted via WebSocket broadcast but the `appendMessage` call still succeeds. The message is saved; only the compaction is skipped.
380
+ | Operation | Current shape | Future direction |
381
+ | ----------------------------------------- | --------------------------------------------- | ---------------------------------------------- |
382
+ | History export and indexing | Stream or bounded batches | Already streamable |
383
+ | Tool-call lookup and reconciliation scans | Host-specific arrays in current chat packages | Can scan batches and stop early |
384
+ | Model request input | AI SDK message array | Must materialize a bounded model window |
385
+ | Compaction summarizer input | Message array | Must materialize a bounded compaction range |
386
+ | Legacy full-transcript client snapshots | Message array | Can move to paginated history plus live deltas |
1019
387
 
1020
- ## Acknowledgments
388
+ The chat packages retain their current public arrays and wire behavior in this release. Moving reconciliation and client history protocols to streaming requires separate protocol work.
1021
389
 
1022
- - Session's tree-structured messages are inspired by [Pi](https://pi.dev).
1023
- - Context blocks are inspired by [Letta AI memory blocks](https://www.letta.com/blog/memory-blocks).
1024
- - Formatting of blocks is inspired by [Hermes Agent](https://github.com/nousresearch/hermes-agent).
390
+ See `examples/next/sessions` for a runnable server-only example.