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: 65cf89e 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
| * 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" = |
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
There was a problem hiding this comment.
Devin Review found 1 new potential issue.
1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)
| try { | ||
| this._options.onChanged?.(nextState, source); | ||
| } catch (error) { | ||
| console.error("StateManager onChanged hook failed:", error); |
There was a problem hiding this comment.
🟡 agents import sizesMeasured 286 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% fails CI. New exports do not affect the gate.
Compared Changed imports (54)
All 286 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. |
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.