feat(agents): move state into an opt-in Lifecycle capability (agents/state) - #2179
feat(agents): move state into an opt-in Lifecycle capability (agents/state)#2179AntoniTok wants to merge 3 commits into
Conversation
🦋 Changeset detectedLatest commit: 4ed2efc 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: |
…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.
f5a1e1f to
4ed2efc
Compare
| * | ||
| * @experimental The API surface may change before stabilizing. | ||
| */ | ||
| export class StateManager<State = unknown> extends LifecycleCapability { |
There was a problem hiding this comment.
🟡 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.
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 { |
There was a problem hiding this comment.
no testing code in the main class
| this.lifecycle | ||
| .use(this.scheduler) | ||
| .use(this.mcp) | ||
| .use(this.#state) |
| * existed; the typed `State` boundary is re-established at the delegating | ||
| * call sites below. | ||
| */ | ||
| readonly #state: StateManager<unknown> = new StateManager<State>({ |
There was a problem hiding this comment.
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] : [] |
| // 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" = |
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 aStateManagercapability that owns storage and change ordering, 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.
Architecture: before then after
Before, one method, four responsibilities, all in the god-class:
After, capability stores and announces; Agent reacts to the announcement:
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):
Inbound, client sends state over WS (source is the connection, echo excludes sender):
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
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.