Skip to content

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

Open
cjol wants to merge 4 commits into
mainfrom
fix-1990-think-cold-rpc
Open

fix(agents): initialize cold native RPC calls#2005
cjol wants to merge 4 commits into
mainfrom
fix-1990-think-cold-rpc

Conversation

@cjol

@cjol cjol commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

This PR initializes Agent and Think instances before native Durable Object 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:

native RPC -> application method
                  |
                  +-> may observe pre-onStart state

After:

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.

Open in Devin Review

…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: 379b5ec

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

This PR includes changesets to release 3 packages
Name Type
agents Patch
@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 devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment thread packages/agents/src/index.ts Outdated
Comment on lines +1617 to +1630
if (this._rpcInitializationState !== "pending") {
return method.apply(this, args);
}

// A native Durable Object RPC invokes the method directly, bypassing
// PartyServer.fetch() and the other entry points that normally run
// onStart(). Initialize inside the invocation context so methods called
// by onStart take the synchronous branch above rather than recursively
// trying to initialize the same Agent.
return this.__unsafe_ensureInitialized().then(() =>
method.apply(this, args)
);
}
);
) as ReturnType<T>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Non-async agent methods can hand back an unfinished value on the very first call after wake-up

On the first call to a public agent method after the object wakes up, the framework hands back a pending value instead of the real result (.then(...) cast with as ReturnType<T> at packages/agents/src/index.ts:1626-1630), so callers of methods that were previously instant get a placeholder they must wait on.
Impact: Code inside the agent (or in the same isolate) that uses the result of a normally-instant method directly can silently work with an unusable placeholder value the first time the agent is touched after a restart.

Wrapper turns synchronous methods into promise-returning ones while initialization is pending

withAgentContext previously always returned method.apply(this, args) directly, so a synchronous public method kept synchronous semantics. The new branch returns this.__unsafe_ensureInitialized().then(() => method.apply(...)) whenever _rpcInitializationState === "pending", i.e. on the first invocation after construction/eviction that is not already inside the agent's invocation context. Because the declared signature is (...args) => ReturnType<T>, the implementation needs an explicit as ReturnType<T> cast (packages/agents/src/index.ts:1630) to hide the mismatch from the type checker.

This matters more than before because _autoWrapRpcMethods (packages/agents/src/index.ts:3653-3696) no longer excludes Agent/Server base methods, so inherited synchronous helpers such as getMcpServers() — which the framework itself consumes synchronously, e.g. mcp: this.getMcpServers() inside JSON.stringify at packages/agents/src/index.ts:2744-2748 — are now wrapped. Any caller that reaches such a method while the state is still "pending" and without the agent already being the current agent receives a promise where a plain value is expected, and the value is serialized/used as an empty object rather than the intended data.

A safer shape would be to only apply the initialization branch to methods that are known to be async (or to keep the synchronous fall-through when method is not an async function), and to avoid the as ReturnType<T> cast so the type system reflects the actual contract.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@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

create-think

npm i https://pkg.pr.new/create-think@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: 379b5ec

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

1 participant