Skip to content

fix(agents): initialize cold native RPC calls - #2005

Merged
threepointone merged 10 commits into
mainfrom
fix-1990-think-cold-rpc
Sep 29, 2026
Merged

threepointone merged 10 commits into
mainfrom
fix-1990-think-cold-rpc

Conversation

@cjol

@cjol cjol commented Jul 31, 2026 •

Copy link
Copy Markdown
Member

Fixes #1990.

When a native Durable Object RPC call is the call that wakes an Agent or Think instance, onStart never ran, because native RPC bypasses the lifecycle's fetch, alarm and webSocket* handlers. An empty class ColdAgent extends Think {} threw on stub.saveMessages([message]), and getMessages() 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)

  • New 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.
  • A start() made from inside that startup (a capability or onStart calling back into the host) returns immediately instead of waiting on itself. An AsyncLocalStorage scope set around startup tells these nested calls apart from independent ones.
  • props passed to start() while a startup is in flight are ignored, so they can't change the props partway through startup.

Agent

  • Agent already wraps public subclass methods to set up getCurrentAgent(). For async methods, that wrapper now awaits lifecycle.start() first when the call comes from outside this Agent and the instance hasn't started yet.
  • Method discovery respects the nearest definition of a name (a subclass getter isn't overwritten), has no depth limit, and doesn't wrap a method twice.
  • The dynamic-agent checkRunFibersAtPath, invoke and invokePath RPC paths call __unsafe_ensureInitialized().

Think

  • Think's public API (saveMessages, getMessages, addMessages, chat, runTurn, …) is defined on the Think class, below Agent, so the wrapper covers it. The same applies to AIChatAgent.
  • The underscore-prefixed _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:

  • Synchronous methods. They are never deferred, so they keep their synchronous return type.
  • Non-async methods that return a Promise.
  • Methods defined on Agent itself, such as getMcpServers() and schedule(). 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() or idFromString(), with no migrated legacy name, now fails its first async RPC with the lifecycle's addressing error. Before, it served the call against uninitialized state. Address Agents by name with getAgentByName(), getByName() or idFromName(). Two MCP OAuth test fixtures moved to idFromName() 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 cold destroy, legacy name migration, and a clear error for unnamed Agents.
  • packages/agents/src/tests/lifecycle/startup.test.ts: covers concurrent start() callers, nested start() from a capability and from onStart, 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.

…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-bot

changeset-bot Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4c4ca95

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

This PR includes changesets to release 3 packages
Name Type
agents Minor
@cloudflare/think Patch
@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

devin-ai-integration[bot]

This comment was marked as resolved.

@pkg-pr-new

pkg-pr-new Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

agents

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

@cloudflare/ai-chat

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

@cloudflare/codemode

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

hono-agents

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

@cloudflare/shell

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

@cloudflare/think

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

@cloudflare/voice

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

@cloudflare/worker-bundler

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

commit: 4c4ca95

@threepointone

Copy link
Copy Markdown
Contributor

I'll review this one

cjol added 2 commits August 24, 2026 10:13
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.
@threepointone

Copy link
Copy Markdown
Contributor

I checked this against current main. The bug is still there: a native RPC call (stub.method()) on a cold instance runs without onStart.

  • withAgentContext sets up context but does not start the agent.
  • Startup runs only from the lifecycle's fetch, alarm and webSocket* handlers, or from the few internal callers of __unsafe_ensureInitialized.
  • Think's saveMessages and getMessages still do not initialize.

The PR conflicts heavily with main: it is 79 commits behind, with 22 conflict hunks in index.ts. State, WebSockets, sessions, facets and the queue have all moved into capabilities since, so many of the _cf_* insertions and the method exclusion list target code that has moved. I would rework it rather than rebase it:

  1. Use the lifecycle's own started state. Check a small public "is started" method on the lifecycle instead of adding a second four-state machine armed from a blockConcurrencyWhile microtask in the constructor.
  2. Start inside withAgentContext. Start the lifecycle there only when the call comes from outside an agent context. That covers every wrapped method without wrapping Agent.prototype behind a hand-kept exclusion list.
  3. Keep synchronous methods synchronous. Today, the first call that wakes the instance makes a sync method such as getMcpServers() return a Promise. Devin flagged this; widening the return type avoids the type error but does not fix the behavior. With (2), sync base methods can stay unwrapped.
  4. Ship the unnamed-Agent change as a minor. An RPC on a newUniqueId() / idFromString() Agent now throws, which needs to be called out in the changeset.

Your tests are the right acceptance suite and should carry over: exactly-once startup, sync policy hooks, destroy on a cold instance, and Think hydration after eviction.

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>
@agent-think

agent-think Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

🟡 agents import sizes: 1 entry point grew

Entry point Exports Largest gzip change Size now
🟡 agents/lifecycle 1 resized +110 B (+1.28%) 8.5 KiB
Changed exports (1)
Import Gzip change Size now
🟡 agents/lifecycle#Lifecycle +110 B (+1.28%) 8.5 KiB
How this works

Each 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 6c8f85a5 → 4c4ca957 · workflow run · reported by agent-think[bot]

devin-ai-integration[bot]

This comment was marked as resolved.

threepointone and others added 2 commits September 29, 2026 13:58
…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>
@threepointone
threepointone merged commit c2f7672 into main Sep 29, 2026
17 checks passed
@threepointone
threepointone deleted the fix-1990-think-cold-rpc branch September 29, 2026 13:25
@github-actions github-actions Bot mentioned this pull request Oct 2, 2026
brandhaug added a commit to brandhaug/b2b-saas-starter that referenced this pull request Oct 5, 2026
## 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>
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.

Agent/Think: an RPC-triggered cold start serves calls against uninitialised state, causing errors and incorrect responses

2 participants