Skip to content

feat(agents): move state into an opt-in Lifecycle capability (agents/state) - #2179

Open
AntoniTok wants to merge 3 commits into
cloudflare:mainfrom
AntoniTok:feat/state-capability
Open

feat(agents): move state into an opt-in Lifecycle capability (agents/state)#2179
AntoniTok wants to merge 3 commits into
cloudflare:mainfrom
AntoniTok:feat/state-capability

Conversation

@AntoniTok

Copy link
Copy Markdown
Contributor

State was one method (_setStateInternal) doing four jobs inside the Agent god-class — validate, persist, broadcast, notify — with the state row, in-memory cache, and schema all threaded through the class. This moves it wholesale into a StateManager capability that owns storage and change ordering, so any Lifecycle host gets durable, validated state without inheriting Agent. Same pattern as the WebSockets (#2169) and MCP client (#1895) capabilities.

Agent's public API and wire protocol are unchanged.

Architecture: before then after

Before, one method, four responsibilities, all in the god-class:

+--------------------------------------+
| Agent (god-class)                    |
|                                      |
|  get state -> SQL load/cache/seed    |
|  _setStateInternal():                |
|    1. validate                       |
|    2. persist ----------+            |
|    3. broadcast --------> clients    |
|    4. notify hook       |            |
|  _state (cache)         v            |
|         cf_agents_state (SQLite)     |
+--------------------------------------+

After, capability stores and announces; Agent reacts to the announcement:

+--------------------------------------+
| Agent (god-class)                    |
|  get state -> #state.get()           |
|  setState  -> #state.set()           |
|  _handleStateChanged() (subscriber): |
|    3. broadcast --------> clients    |
|    4. notify hook                    |
|         ^                            |
|         | onStateChanged (Emitter)   |
+---------|----------------------------+
          |
+---------+----------------------------+
| StateManager (capability)            |
|  onStart: schema + own version key   |
|  get(): load / cache / seed          |
|  set(): 1.validate 2.persist         |
|         -> fire onStateChanged       |
|  _state (cache)                      |
+---------+----------------------------+
          | this.lifecycle.storage
          v
   cf_agents_state (SQLite)

The capability never references connections, the Agent, env, or ctx. It fires a typed onStateChanged emitter (mirroring MCP's onServerStateChanged), and the Agent subscribes to do the WebSockets-specific work.

Data flows

Outbound, server sets state (source is "server", broadcast to all):

dev code -> setState(next)
  -> #state.set(next, "server")
       1. validate  (injected -> Agent.validateStateChange)
       2. persist   -> cf_agents_state
       3. fire onStateChanged({ state, sourceId: undefined })
  -> Agent._handleStateChanged
       3. _broadcastProtocol(...)  -> ALL protocol clients (exclude none)
       4. waitUntil -> onStateChanged / onStateUpdate dev hook

Inbound, client sends state over WS (source is the connection, echo excludes sender):

browser -> onMessage           (WebSockets concern, STAYS in Agent)
   parse + readonly check + CF_AGENT_STATE_ERROR responses
   -> #state.set(next, connection)
        1. validate  2. persist
        3. fire onStateChanged({ state, sourceId: connection.id })
  -> Agent._handleStateChanged
        3. _broadcastProtocol(..., exclude=[sourceId])  -> all EXCEPT sender
        4. dev hook

Both paths funnel through #state.set(); the only difference is sourceId, which drives broadcast exclusion and is forwarded to the notify hook.

What moved vs. what stays

Concern Owner
state row, load/cache/seed, validate + persist StateManager
own schema version key (cf_agents:state_schema_version) StateManager
broadcast to connections Agent (_handleStateChanged)
dev hooks (validateStateChange, onStateChanged/onStateUpdate) Agent (override surface)
onMessage parse / readonly / error responses Agent / WebSockets
cf_agents_state table creation both, idempotent

Host-owned behavior is injected, not moved: validateStateChange stays an overridable Agent method, and initialState is resolved lazily so a subclass field, initialized after the base constructor, is read at its final value.

The cf_agents_state table is shared: StateManager ensures it in onStart and owns the state row, while Agent keeps its global schema-version row in _ensureSchema and ensures the table there too. Each side is idempotent and tracks its own version, the same pattern as Scheduler's ensureScheduleTable.

Compatibility

state, setState(), onStateChanged, and the CF_AGENT_STATE frames behave identically. The full Agent state suite (22 cases) and the schema suite pass as-is; the only test change replaces a fixture reach-in to the removed _state cache with a proper reset method.

Tests

New capability suites install StateManager on a bare Durable Object through withCapabilityHarness and cover: persist/read, initial-state seeding, falsy-value row existence, rehydration across a simulated eviction, injected-validation rejection, and the onStateChanged source-exclusion payload on both server and client origins.

@changeset-bot

changeset-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4ed2efc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
agents Minor
@cloudflare/agent-think Patch

Not sure what this means? Click here to learn what changesets are.

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

@pkg-pr-new

pkg-pr-new Bot commented Aug 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2179

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2179

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2179

hono-agents

npm i https://pkg.pr.new/hono-agents@2179

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2179

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2179

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2179

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2179

commit: 4ed2efc

Antoni T added 3 commits September 1, 2026 10:28
…state)

State was one method doing four jobs inside the Agent god-class —
validate, persist, broadcast, notify — with the state row, cache, and
schema all threaded through the class. It moves wholesale into a
StateManager capability that owns storage and change ordering, so any
Lifecycle host gets durable, validated state without inheriting Agent:

  new StateManager({
    resolveInitialState: () => ({ value: this.initialState }),
    validateStateChange: (next, source) => this.validateStateChange(next, source)
  })

The capability owns the cf_agents_state state row, lazy load with an
in-memory cache, initial-state seeding, and validated persistence. It
runs only the onStart hook (versioned schema init under its own
cf_agents:state_schema_version key) and reaches Lifecycle only for
storage — no alarm, no request path. It never touches connections:
after validate + persist it fires a typed onStateChanged emitter,
mirroring the MCP client's onServerStateChanged seam. The getter,
write path, and corrupt-row fallback move verbatim; the only changes
are ctx->lifecycle storage and the broadcast becoming the emitter.

Host-owned behavior is injected, not moved: validateStateChange stays
an overridable Agent method, and initialState is resolved lazily so a
subclass field — initialized after the base constructor — is read at
its final value.

Broadcast and the notification hook stay on the Agent as an
onStateChanged subscriber (_handleStateChanged): it broadcasts
CF_AGENT_STATE to protocol-enabled connections excluding the source
id, then runs onStateChanged/onStateUpdate off the invocation tail.
The onMessage state branch stays too — parse, readonly check, and
CF_AGENT_STATE_ERROR responses are WebSockets concerns; only its inner
write becomes #state.set(state, connection). Agent installs the
capability in the .use() chain and delegates state/setState to it.

The cf_agents_state table is shared: StateManager ensures it in
onStart and owns the state row, while Agent keeps its global
schema-version row in _ensureSchema and ensures the table there too —
each side idempotent, each tracking its own version, the same pattern
as Scheduler's ensureScheduleTable.

Agent's public API and wire protocol are unchanged — state,
setState(), onStateChanged, and the CF_AGENT_STATE frames behave
identically, and the full Agent state suite (22 cases) plus the schema
suite pass as-is. New capability suites install StateManager on a bare
Durable Object through withCapabilityHarness and cover persist/read,
initial-state seeding, falsy-value row existence, rehydration across a
simulated eviction, injected-validation rejection, and the
onStateChanged source-exclusion payload on both server and client
origins.
@AntoniTok
AntoniTok force-pushed the feat/state-capability branch from f5a1e1f to 4ed2efc Compare September 1, 2026 09:48
@AntoniTok
AntoniTok marked this pull request as ready for review September 1, 2026 10:00

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 potential issue.

Devin Review

*
* @experimental The API surface may change before stabilizing.
*/
export class StateManager<State = unknown> extends LifecycleCapability {

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.

🟡 State capability cannot be imported

The new StateManager lacks a package export and build entry. Consumers cannot import the advertised agents/state capability.

Prompt for agents
Expose the StateManager capability as the advertised agents/state public entry point. Add packages/agents/src/state/index.ts to packages/agents/scripts/build.ts, add the matching ./state types/import/require mapping to packages/agents/package.json, and add an entry-point/type-level test consistent with the other capability exports. Verify the built dist/state/index.js and dist/state/index.d.ts artifacts are produced and importable.
Devin Review

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

* absent. Mirrors a hibernation wake-up; for host-internal use and tests
* that exercise the lazy-load path in a single live instance.
*/
__DO_NOT_USE__resetStateCacheForTesting(): void {

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.

no testing code in the main class

this.lifecycle
.use(this.scheduler)
.use(this.mcp)
.use(this.#state)

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.

this._state.

* existed; the typed `State` boundary is re-established at the delegating
* call sites below.
*/
readonly #state: StateManager<unknown> = new StateManager<State>({

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.

call this State.

let me pass a hook to do stuff on changed. and pass the initial state. dont need a setter I think it can be static.

type: MessageType.CF_AGENT_STATE
}),
source !== "server" ? [source.id] : []
sourceId !== undefined ? [sourceId] : []

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.

huh?

// Notification hook (non-gating). Run after broadcast and do not block.
// Use waitUntil for reliability after the handler returns.
const { connection, request, email } = agentContext.getStore() || {};
const source: Connection | "server" =

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.

🪦

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants