Repository navigation
fix(agents): initialize cold native RPC calls - #2005
Conversation
…bject RPC methods run on a cold instance. Fixes #1990. ## Why - Native Durable Object RPC invokes methods directly, bypassing the PartyServer entry points that normally run `onStart()`. A method that wakes an evicted Agent can therefore observe missing session state, stale caches, unrestored MCP connections, or incomplete recovery. - `getAgentByName()` cannot provide a lasting guarantee because stubs outlive the in-memory instances they address. Eviction or deployment can make any later RPC the next cold-start entry point. - Requiring every inherited and application-defined RPC method to call `__unsafe_ensureInitialized()` would expose framework lifecycle concerns to users and leave room for inconsistent coverage. - Eagerly starting the lifecycle from the base constructor would run before derived fields and constructors finish. Instead, this change extends the existing method-context wrapper, arms it after construction, and initializes only when a public application RPC is the cold entry point. ## Architectural Changes Before: ```text native RPC -> application method | +-> may observe pre-onStart state ``` After: ```text native RPC -> Agent invocation wrapper -> PartyServer initialization -> application method | +-> lifecycle/control-plane methods retain their existing dispatch ``` - Agent tracks whether RPC initialization is constructing, pending, starting, or started so startup extension points can call public methods without recursively initializing. - Prototype discovery now wraps inherited Agent and application methods while preserving callable metadata and the nearest property descriptor. - PartyServer lifecycle and control-plane methods, plus underscore-prefixed framework internals, remain outside the application RPC wrapper. ## Code Changes - `agents` initializes cold public application RPC calls through `__unsafe_ensureInitialized()` and delays arming the guard until derived construction completes. - `agents` preserves synchronous constructor-local calls, inherited descriptor shadowing, and existing WebSocket `@callable` behavior. - `@cloudflare/think` marks its startup chain as in progress while it initializes session, workspace, message, protocol, and recovery state before invoking extension points. - The OAuth provider fixtures now use named IDs because their application RPCs enter Agent startup. An unbootstrapped `newUniqueId()` has no resolvable PartyServer name and is not a valid initialized Agent instance. - Patch changesets cover both `agents` and `@cloudflare/think`. ## Compatibility - Native Durable Object RPC does not require `@callable`. The decorator remains the explicit allowlist for RPC exposed through Agent WebSocket clients. - Agents addressed through `idFromName()` or `getByName()` initialize without an extra handshake. - A raw `newUniqueId()` or `idFromString()` instance must call `setName()` before its first application RPC. Once bootstrapped, PartyServer persists the name and recovers it after eviction. Previously, name-independent methods could appear to work by running against uninitialized Agent state.
🦋 Changeset detectedLatest commit: 4c4ca95 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 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: |
|
I'll review this one |
Main vendored the PartyServer runtime into packages/agents/src/lifecycle and dropped the `Server` base class (#2133). `Agent` now extends `DurableObject` directly and owns a `Lifecycle`, which moved the whole cold-RPC boundary this branch depends on. Resolutions: - `__unsafe_ensureInitialized()` keeps its cold-RPC guard but now starts the lifecycle instead of delegating to a `Server` supermethod. - The wrapper exclusion set can no longer be derived from `Server.prototype`. The platform-owned part still comes from `DurableObject.prototype`; the lifecycle callbacks come from a new exported `LifecycleHostCallback` union checked with `satisfies`, so a new callback cannot silently become wrappable; the remaining runtime entry points and lifecycle accessors are listed explicitly. - Sub-agent connection RPC combines this branch's initialization with main's `this.lifecycle.getConnection()`. - `setName()` no longer exists, so the naming regressions now cover the lifecycle's read-only `__ps_name` migration and its addressing error. - The changeset and `docs/agents/lifecycle.md` describe the lifecycle contract rather than PartyServer bootstrapping. Also removes stale local build output for the examples and package that #2063 deleted. Validated with `pnpm run check` (114 projects) and `nx run-many -t test` for `agents` (2605) and `@cloudflare/think` (890).
The lifecycle RFC prescribes an explicit `await this.lifecycle.start()` for RPC methods that need startup. That contract does not reach #1990's repro, where an empty subclass exposes framework methods over RPC and there is no user code in the call path. Records the problem with the reproduced symptoms, the widened prototype wrapping, the three alternatives weighed (documented boilerplate, explicit calls in every public method, wrapping only framework prototypes), and the accepted decision with its costs: 145 framework methods wrapped alongside the existing 84 user methods, and a declared-synchronous signature that returns a Promise on the cold path. Notes that wrapping predates the lifecycle vendoring rather than being reintroduced by this work, since that is the first question the approach invites. Adds the coverage-matrix row for the new regressions.
|
I checked this against current
The PR conflicts heavily with
Your tests are the right acceptance suite and should carry over: exactly-once startup, sync policy hooks, |
Resolve conflicts with the Durable Object lifecycle and capability work on main by taking main's Agent and Think, then reapplying the fix in the shape requested in review: - Lifecycle gains isStarted(), and start() returns immediately when called from inside a running startup instead of re-entering it. - withAgentContext starts the lifecycle before an async subclass method runs when the call arrives outside this Agent's context on a cold instance. Synchronous methods are never deferred and Agent.prototype methods stay unwrapped, so no exclusion list and no second state machine. - Method discovery honours the nearest descriptor, drops the depth limit and skips functions that are already wrappers. - Think host-bridge methods and the dynamic-agent checkRunFibersAtPath, invoke and invokePath RPCs call __unsafe_ensureInitialized() directly. - The changeset is now a minor for agents: an async RPC on an unnamed Agent now fails with the lifecycle's addressing error. Co-authored-by: Cursor <cursoragent@cursor.com>
🟡 agents import sizes: 1 entry point grew
Changed exports (1)
How this worksEach runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement. Compared |
…lers Independent start() callers now await the same in-flight startup and see its failure; only calls made from inside startup, marked with an AsyncLocalStorage scope, return immediately. Props passed while a startup is in flight are ignored. Add lifecycle startup tests for concurrent, nested and failing startup, and describe automatic cold RPC startup in the lifecycle design doc. Co-authored-by: Cursor <cursoragent@cursor.com>
lifecycle.ready() previously returned as soon as status was "starting", so an independent capability operation arriving mid-startup ran before onStart finished. It now uses the same rule as start(): calls from inside startup return immediately, independent callers await the in-flight startup and see its rejection. Co-authored-by: Cursor <cursoragent@cursor.com>
## pnpm-workspace.yaml (default) ## Dependency Updates | Package | From | To | Type | | --- | --- | --- | --- | | `agents` | 0.24.0 | 0.25.0 | minor | ## Release Notes <details> <summary><b>agents</b> (0.24.0 → 0.25.0)</summary> ### Minor Changes - [#2005](cloudflare/agents#2005) [`c2f7672`](cloudflare/agents@c2f7672) Thanks [@<!---->cjol](https://github.com/cjol)! - Native RPC calls to async Agent and Think methods now start lifecycle initialization first; address Agents by name because raw IDs from `newUniqueId()` and `idFromString()` now fail their first async RPC. See [Lifecycle](https://github.com/cloudflare/agents/blob/main/docs/agents/lifecycle.md). ### Patch Changes - [#2390](cloudflare/agents#2390) [`c55ec80`](cloudflare/agents@c55ec80) Thanks [@<!---->threepointone](https://github.com/threepointone)! - Report a failed agent-tool child as failed even when it was evicted before recording the failure. See [Agent tools](https://github.com/cloudflare/agents/blob/main/docs/agents/agent-tools.md). - [#2384](cloudflare/agents#2384) [`f904999`](cloudflare/agents@f904999) Thanks [@<!---->threepointone](https://github.com/threepointone)! - Fix agent-tool chunks being duplicated or dropped on reconnect, child re-attach, and fiber recovery. See [Agent tools](https://github.com/cloudflare/agents/blob/main/docs/agents/agent-tools.md). - [#2364](cloudflare/agents#2364) [`5e0507e`](cloudflare/agents@5e0507e) Thanks [@<!---->threepointone](https://github.com/threepointone)! - Add `eventDelivery: "terminal"` to `runAgentTool` to forward only lifecycle, progress, and milestone events for a run. See [Agent tools](https://github.com/cloudflare/agents/blob/main/docs/agents/agent-tools.md). - [#2448](cloudflare/agents#2448) [`54f9ca7`](cloudflare/agents@54f9ca7) Thanks [@<!---->aron-cf](https://github.com/aron-cf)! …[full notes](https://github.com/cloudflare/agents/releases/tag/agents%400.25.0) </details> --- *This PR was auto-generated by [catalog-update-action](https://github.com/brandhaug/catalog-update-action).* Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Fixes #1990.
When a native Durable Object RPC call is the call that wakes an
AgentorThinkinstance,onStartnever ran, because native RPC bypasses the lifecycle'sfetch,alarmandwebSocket*handlers. An emptyclass ColdAgent extends Think {}threw onstub.saveMessages([message]), andgetMessages()quietly returned[]for a conversation that had messages.This PR starts the lifecycle before those calls run. It follows the review on this PR: no second state machine, no hand-kept exclusion list, and synchronous methods stay synchronous.
What changed
Lifecycle(agents/lifecycle)isStarted(): returns whether startup has finished.start()shares one in-flight startup between concurrent callers. Every independent caller waits for it and gets its result, including a failure.start()made from inside that startup (a capability oronStartcalling back into the host) returns immediately instead of waiting on itself. AnAsyncLocalStoragescope set around startup tells these nested calls apart from independent ones.propspassed tostart()while a startup is in flight are ignored, so they can't change the props partway through startup.AgentAgentalready wraps public subclass methods to set upgetCurrentAgent(). Forasyncmethods, that wrapper now awaitslifecycle.start()first when the call comes from outside this Agent and the instance hasn't started yet.checkRunFibersAtPath,invokeandinvokePathRPC paths call__unsafe_ensureInitialized().ThinksaveMessages,getMessages,addMessages,chat,runTurn, …) is defined on the Think class, belowAgent, so the wrapper covers it. The same applies to AIChatAgent._host*bridge methods call__unsafe_ensureInitialized()themselves, because the wrapper skips underscore names.What is not covered
These still run without startup on a cold call unless they call
await this.lifecycle.start()themselves:asyncmethods that return a Promise.Agentitself, such asgetMcpServers()andschedule(). These are not wrapped. SQL-backed reads are still correct cold; in-memory state such as live MCP connections is not.Compatibility
This is a minor bump for
agents(patch for@cloudflare/think).Startup needs the object's name. An Agent addressed with
newUniqueId()oridFromString(), with no migrated legacy name, now fails its firstasyncRPC with the lifecycle's addressing error. Before, it served the call against uninitialized state. Address Agents by name withgetAgentByName(),getByName()oridFromName(). Two MCP OAuth test fixtures moved toidFromName()for this reason.Tests
packages/agents/src/tests/native-rpc-initialization.test.ts: covers startup before async subclass RPC and before_cf_*RPC. Sync subclass and inherited methods stay synchronous and don't start the instance. Also covers exactly-once startup, a subclass getter that shadows a method, a colddestroy, legacy name migration, and a clear error for unnamed Agents.packages/agents/src/tests/lifecycle/startup.test.ts: covers concurrentstart()callers, nestedstart()from a capability and fromonStart, props not being replaced mid-startup, and failed startup rejecting every caller.packages/think/src/tests/cold-rpc-initialization.test.ts: covers Think startup before inherited and subclass RPC, and history hydration after eviction for both RPC and the host bridge.Design record:
design/rfc-cold-rpc-initialization.md.