feat(agents): move state into an opt-in Lifecycle capability (agents/state) - #2179
Conversation
🦋 Changeset detectedLatest commit: e5331fe The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
f5a1e1f to
4ed2efc
Compare
4ed2efc to
9d170ac
Compare
9d170ac to
e1a785a
Compare
…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.
e1a785a to
1e8f967
Compare
🟡 agents import sizesMeasured 337 runtime imports as minified bundles. The primary size is gzip; raw minified size is included for diagnosis. An existing import growing by more than 10% is marked red. This report is informational.
Compared Changed imports (53)
All 337 current runtime imports
Reported by agent-think[bot]. |
|
|
I assume you didn’t want me to change StateManager to State because State already names the agent’s state type and would just be confusing. Anyway #state is now _state. Anyways now initialState passes the starting value, and onChanged passes the hook that runs after state changes; no initial-state setter is needed. The wierd stuff marked by your last 2 comments was from splitting the state-change logic into two functions connected by an event causing us to lose the original connection value, so the code had to fake it back. Fixed now. Devin flagged that async onChanged promises were not handled. Agent state changes now use the existing ctx.waitUntil, while standalone StateManager instances catch rejected promises. No Lifecycle API or behavior changed. |
…from Agent - Rename the capability class to State (StateOptions), matching the other capability names; Agent imports it as StateCapability since State is its type parameter. - Drop the lazy initialState getter: State takes plain static options. Agent keeps initialState as a field and seeds it from the state getter, so the capability no longer reaches back into the host. - Remove the stray divider comment in onMessage.
…ts_state Agent kept its global schema version as a row in cf_agents_state, which was the only reason it still created the table alongside the State capability. The version now lives under the cf_agents:schema_version KV key (read synchronously via storage.kv so the constructor can still gate DDL), matching how every capability stores its own version. A DO created under the old layout has the row read once, moved to the key, and deleted. The legacy cf_state_was_changed cleanup moves into State's own v1 migration. Agent no longer touches the table outside that one-time read.
Capabilities already hold storage, and sql hangs off it, so every sibling runs storage.sql.exec directly. State now does the same and the services contract stays as it was. Fold the one-interface options module into state/index.ts, drop the unused generic on StateChangeSource, and shorten the Agent field comment.
Frees the State name for the capability class so Agent imports it directly instead of aliasing. Type parameters are positional for consumers, so this is not a breaking change.
Matches Streams, Tasks, Sessions, Scheduler, and Lifecycle, which use ECMAScript private fields throughout.
Devin review on cloudflare#2179: set() cached nextState before serializing and writing it, so a JSON.stringify or SQLite failure left later get() calls returning a value storage and onChanged never received.
Also guard the async-hook branch with instanceof Promise so a hook that returns a non-promise value at runtime cannot crash set().
Handles thenables that are not instances of this realm's Promise while still tolerating non-promise return values.
Devin on cloudflare#2179: an onChanged hook that keeps working after it returns could be abandoned once the invocation that set the state ended. LifecycleServices gains waitUntil (ctx.waitUntil behind the narrow services surface) and State.set registers the hook's continuation with it, still without awaiting it.
…e runtime" This reverts commit 04c5483.
mattzcarey
left a comment
There was a problem hiding this comment.
Reviewed and iterated on this together: State capability owns cf_agents_state outright, Agent's schema version moved to a KV key, static options, TState rename, # privates. CI green, no open threads.
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 storage and change ordering into aStatecapability (agents/state), so any Lifecycle host gets durable, validated state without inheritingAgent. Same pattern as the WebSockets (#2169) and MCP client (#1895) capabilities.Agent's public API and wire protocol are unchanged.
API
StateOptionsis plain data:initialState,validateStateChange,onChanged.StateChangeSourceisConnection | "server". AsynconChangedis tracked and its rejection logged;set()stays synchronous. The capability reaches Lifecycle only forstorageand uses#private fields like its siblings.Architecture
The capability never references connections, the Agent, env, or ctx. Agent passes two closures (
validateStateChange,onChanged) as static options and does the WebSockets-specific work itself. Agent's generic parameter is renamedState→TState(positional for consumers, so not a breaking change) so the class can be imported as plainState.Data flows
Server sets state (
source = "server", broadcast to all):Client sends state over WS (
source = connection, echo excludes sender):What moved vs. what stays
cf_agents_statetable, state row, load/cache, validate + persistcf_state_was_changedcleanupcf_agents:state_schema_version)initialStatefield and first-access seedingstategetter)_handleStateChanged)validateStateChange,onStateChanged/onStateUpdate)onMessageparse / readonly / error responsesinitialStatestays an Agent field because subclass fields initialize after the base constructor. Rather than the capability reaching back into the host lazily, Agent'sstategetter seeds it on first read when the capability returnsundefined(not JSON-representable, so it uniquely means "no row"). The seed goes throughset(), so it persists, broadcasts, and runs the hook as before.Table ownership
Stateis the only owner ofcf_agents_state. Agent used to keep its global schema version as a row in that table, which was the only reason it still created the table. That version now lives under thecf_agents:schema_versionKV key, read synchronously viastorage.kvso the constructor can still gate DDL — the same convention every capability uses. A DO created under the old layout has the row read once, moved to the key, and deleted on its next construction.Compatibility
state,setState(),onStateChanged, and theCF_AGENT_STATEframes behave identically. The full Agent state suite and the schema suite pass; the fixture reach-ins to the removed_statecache are replaced by eviction (evictDurableObject) or by driving the capability's own migration.Tests
Capability suite installs
Stateon a bare Durable Object throughwithCapabilityHarness: persist/read, access before lifecycle start, initial-state seeding, falsy-value row existence, rehydration across eviction, validation rejection,onChangedsource on both origins, sync/async hook failures. Schema suite adds a legacy schema-version-row migration case.