Skip to content

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

Description

@benjamincburns

On a cold Durable Object instance, submitMessages() works and saveMessages() throws. Both are public RPC methods on the same object.

The difference is that submitMessages persists its work and defers execution to an alarm that runs onStart(), while saveMessages assumes that onStart() has already run. Native DO RPC never triggers onStart. partyserver runs onStart from fetch, alarm, the webSocket* handlers, and its own setName / __unsafe_ensureInitialized RPCs, but not from anything else. So any public or user-defined RPC method that touches state that onStart establishes fails whenever it is the call that wakes the instance, which is the normal case for an agent that goes idle between messages, due to DO eviction policies.

For Think the missing state is session, workspace, the hydrated message view, protocol handlers and recovery. chat, runTurn, getMessages and the history mutators all depend on this state. Agent.onStart also restores MCP connections and drives fiber and agent-tool recovery, and a subclass's own onStart hasn't run either.

Reproduction

package.json

{
  "name": "think-cold-rpc-mre",
  "private": true,
  "type": "module",
  "dependencies": {
    "@cloudflare/think": "0.15.0",
    "agents": "0.20.0",
    "ai": "^6.0.0"
  },
  "devDependencies": {
    "wrangler": "^4.114.0"
  }
}

wrangler.jsonc

{
  "name": "think-cold-rpc-mre",
  "main": "src/index.ts",
  "compatibility_date": "2026-06-11",
  "compatibility_flags": ["nodejs_compat"],
  "durable_objects": {
    "bindings": [{ "class_name": "ColdAgent", "name": "ColdAgent" }]
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ColdAgent"] }]
}

src/index.ts

import { Think } from "@cloudflare/think";
import type { UIMessage } from "ai";

type Env = {
  ColdAgent: DurableObjectNamespace<ColdAgent>;
};

export class ColdAgent extends Think<Env> {}

export default {
  async fetch(_req: Request, env: Env): Promise<Response> {
    const stub = env.ColdAgent.get(env.ColdAgent.idFromName("mre"));

    // This instance has never run, so the RPC below cold-starts it and
    // this.session is still undefined when saveMessages reaches it.
    const message: UIMessage = {
      id: "m1",
      role: "user",
      parts: [{ type: "text", text: "hi" }]
    };
    await stub.saveMessages([message]);
    return new Response("ok");
  }
} satisfies ExportedHandler<Env>;
npm install
npx wrangler dev --port 8899 &
curl http://localhost:8899/

Observed:

TypeError: Cannot read properties of undefined (reading 'appendMessage')

Expected: the call behaves as it would on an initialized instance.

The MRE uses a never-woken name for brevity, but the same failure reproduces on an established agent by evicting it and calling any of those entrypoints on the existing stub.

Other failure modes

The missing state doesn't always result in a throw. Where the missing state is a cache rather than an object, the call succeeds with a wrong answer and no error:

  • getMessages() returns [] — an empty transcript for a conversation that has one.
  • getMcpServers() reports every registered server as not-connected, with an empty tool list.

Why the caller can't close this

getAgentByName does close it, by making one extra RPC (setName) that synchronises onStart. But that guarantee belongs to the instance it initialised, and stubs outlive instances. Any hibernation, eviction or deployment between acquisition and use can cause the DO belonging to the stub to go cold.

Callers who can't reach the helper have it worse. getAgentByName needs a parameterised namespace, so cross-script bindings and stubs received over RPC are left hand-rolling the handshake as stub.setName(name), which is deprecated, even though getServerByName relies on it.

What I'd like

Ideally I'd like a solution that requires no per-method boilerplate. Initialisation on cold start is a framework responsibility, and every entrypoint the framework owns already upholds it. agents opens its own internal RPC entrypoints with __unsafe_ensureInitialized(). Application-defined entrypoints are the gap.

I can live with a solution that requires some level of boilerplate, but that would erode some of the benefit that I expected from Think. I decided to trial Think mostly because I didn't want to have to handle eviction and cold starts in my application code.

I'd be happy to work on a PR for this, but I'd like to know what type of solution the maintainers would be willing to accept before I start, as I suspect that fixing this properly may require some breaking changes, and may require enforcing some restrictions on the public API surface (e.g. everything must be async).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions