Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/cold-rpc-starts-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"agents": minor
"@cloudflare/think": patch
---

Start the lifecycle before an `async` Agent or Think method runs over native Durable Object RPC on a cold instance, so `onStart` has run and Think's session, messages and workspace are ready. Synchronous methods and methods inherited from `Agent` are unchanged. Adds `lifecycle.isStarted()`, and concurrent `lifecycle.start()` callers now share one in-flight startup and all see its result.

Breaking: startup resolves the object's name, so an Agent addressed with `newUniqueId()` or `idFromString()` now fails its first `async` RPC with the lifecycle's addressing error instead of serving the call against uninitialized state. Address Agents by name with `getAgentByName()`, `getByName()`, or `idFromName()`.
1 change: 1 addition & 0 deletions design/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ Keep it concise. A few paragraphs is fine. These are records, not essays.
| `mcp.md` | design doc | Stateless, Legacy compatibility, Legacy sessionful, client, package boundary, and conformance architecture |
| `durable-object-lifecycle.md` | design doc | Lifecycle Objects, capability and host phases, Scheduler/alarm ownership, host context, identity, and always-hibernating WebSockets |
| `rfc-durable-object-lifecycle.md` | RFC | Constructor-composed Durable Object lifecycle with reusable components and always-hibernating WebSockets |
| `rfc-cold-rpc-initialization.md` | RFC | Cold native RPC runs lifecycle startup from the Agent context wrapper for async methods; sync methods stay sync (accepted) |
| `rfc-fibers.md` | RFC | Tasks (née Fibers) — durable replayable execution as one Lifecycle capability: named definitions, journaled steps, sleeps, optional recovery callback (accepted, amended) |
| `rfc-streams.md` | RFC | Streams — durable incremental output as a Lifecycle capability: chunk log, cursor, replay-then-tail reads, composed with Tasks via checkpointed cursors (proposed) |
| `rfc-pi-harness-example.md` | RFC | Pi `AgentHarness` as an example-local Lifecycle capability over Tasks, Streams, and WebSockets; what must land before it becomes a package export (proposed) |
Expand Down
26 changes: 23 additions & 3 deletions design/durable-object-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,15 @@ first `Response` wins; otherwise the host's `onRequest` handles the request. For
an alarm, capability `onAlarm` hooks run in registration order before the
host's `onAlarm`.

Failures stop the phase and propagate. Failed startup remains retryable. Native
RPC methods explicitly call `lifecycle.start()` because native Durable Object
RPC bypasses Lifecycle handlers.
Failures stop the phase and propagate. Failed startup remains retryable.

Native Durable Object RPC bypasses Lifecycle handlers, so a plain Lifecycle host
calls `lifecycle.start()` from any RPC method that needs startup. Concurrent
`start()` callers share one in-flight startup and all see its result. A call
made from inside that startup (a capability or `onStart` calling back into the
host) returns immediately instead of waiting on itself; an async-context marker
tells those nested calls apart from independent ones. `lifecycle.isStarted()`
reports whether startup has finished.

## Alarm ownership and scheduling

Expand Down Expand Up @@ -158,6 +164,20 @@ In particular, the automatic public-method wrapper is not made redundant by
Lifecycle because native Durable Object RPC does not pass through a Lifecycle
handler.

That wrapper also starts the Lifecycle. When an `async` method defined below
`Agent` (a user subclass method, or Think's and AIChatAgent's public API) is
entered from outside this Agent's context on an unstarted instance, it awaits
`lifecycle.start()` before running. Framework `_cf_*` RPCs and Think's `_host*`
bridge methods call `__unsafe_ensureInitialized()` themselves. These still run
without startup unless they call `await this.lifecycle.start()`:

- synchronous methods, which are never deferred so they keep their return type;
- non-`async` methods that return a Promise;
- methods defined on `Agent` itself, such as `getMcpServers()` and
`schedule()`, which are not wrapped.

See [rfc-cold-rpc-initialization.md](./rfc-cold-rpc-initialization.md).

Tracing remains in Agent's existing invocation boundaries. Moving or
consolidating tracing in Lifecycle is separate work.

Expand Down
117 changes: 117 additions & 0 deletions design/rfc-cold-rpc-initialization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
Status: accepted

# Cold native RPC initialization

## Problem

Native Durable Object RPC dispatches straight onto the instance. The lifecycle
starts from the five handlers it installs — `fetch`, `alarm`,
`webSocketMessage`, `webSocketClose`, `webSocketError` — and from an explicit
`lifecycle.start()`. An RPC method is none of those, so `onStart` never runs
when the RPC is the call that wakes a cold instance. For an agent that idles
between messages, that is the normal case.

[#1990](https://github.com/cloudflare/agents/issues/1990) reports two symptoms.
An empty `class ColdAgent extends Think {}` and a single
`stub.saveMessages([message])` reproduces the first:

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

`Think.session` is declared `session!: Session` with no initializer and is
assigned during startup, so a cold RPC reaches it as `undefined`.

The second symptom is quieter and worse: a read of state that startup hydrates
returns the wrong answer with no error. `getMessages()` returns `[]` for a
conversation that has one, and a caller cannot distinguish that from a genuinely
empty result.

`Agent` already treats initialization as an entry-point responsibility for its
framework-internal `_cf_*` and `_workflow_*` RPCs, which call
`__unsafe_ensureInitialized()`. The surface users actually dial is the gap.

## Proposal

Start the lifecycle from the wrapper `Agent` already installs around subclass
methods.

The constructor calls `_autoWrapCustomMethods()` once per class. It walks the
prototype chain below `Agent.prototype` and replaces each public method with
`withAgentContext`, which establishes `getCurrentAgent()` context for calls that
arrive outside a lifecycle hook — native RPC being the main one. For `async`
methods the wrapper gains one branch:

```ts
if (getCurrentAgent().agent !== this && !this.lifecycle.isStarted()) {
await this.lifecycle.start();
}
```

- **The lifecycle owns the state.** `Lifecycle.isStarted()` exposes the
lifecycle's own status; there is no second state machine on `Agent`.
- **Calls from inside this Agent never start it.** `onStart`, lifecycle hooks
and wrapped methods all run with this Agent as the current agent, so startup
calling its own public methods does not recurse. Independent `start()`
callers share the one in-flight startup and see its result, including a
failure. A `start()` made from inside that startup (for example a capability
calling an async host method) returns immediately; Lifecycle marks the
startup's async context with an `AsyncLocalStorage` scope to tell the two
apart, and ignores `props` passed while a startup is in flight.
- **Synchronous methods stay synchronous.** Only functions that are `async` get
the startup branch. A sync method keeps its return type on every path,
including the one that wakes the instance, and a subclass constructor or field
initializer calling its own sync helpers is unaffected.
- **Base `Agent` methods stay unwrapped.** Methods on `Agent.prototype` —
connection policy hooks, `destroy()`, `getMcpServers()` and the rest — are not
wrapped, so their signatures and the framework's synchronous decisions inside
entry points are unchanged, and a cold alarm can still finish deleting a
condemned Agent without startup recreating state.

Think's public API (`saveMessages`, `getMessages`, `addMessages`, `chat`,
`runTurn`, …) lives on `Think.prototype`, below `Agent.prototype`, so it is
covered. Think's underscore-prefixed host bridge methods and the dynamic agent
path RPCs are skipped by wrapping and call `__unsafe_ensureInitialized()`
directly.

Method discovery treats the nearest descriptor for a name as authoritative, so a
subclass getter shadowing an inherited method is not replaced. It no longer stops
at a fixed depth, and it does not re-wrap a function that is already a wrapper.

## Costs

- A non-`async` method that returns a Promise, or a synchronous method that
reads state `onStart` hydrates, is not started by the wrapper. Declare it
`async` or call `await this.lifecycle.start()` in it.
- Methods on `Agent.prototype` called over native RPC on a cold instance still
run before startup. SQL-backed reads are correct cold because `_ensureSchema()`
runs in the constructor; in-memory state such as live MCP connections is not.
- Startup resolves the object's name. An Agent addressed with `newUniqueId()` or
`idFromString()` without a migrated legacy name now fails its first `async`
subclass RPC with the lifecycle's addressing error instead of serving the call
against uninitialized state. This ships as a minor release.

## Alternatives

**Document `await this.lifecycle.start()` and require callers to add it.** This
is what `design/rfc-durable-object-lifecycle.md` prescribes. It does not reach
the reported bug: the repro is an empty subclass calling a framework method on
the stub, so there is no user code in the path.

**Call `lifecycle.start()` explicitly from every public method.** Around 40
synchronous methods on `Agent` and `Think` would have to become async, breaking
every subclass overriding `getModel()` or `getTools()`, and a public method
added later silently misses the call.

**Wrap `Agent.prototype` too, with a hand-kept exclusion list.** An earlier
revision of this change did this, with a four-state field armed from a
constructor microtask. It made synchronous methods return a Promise on the cold
path while TypeScript still showed the synchronous signature, and every new
runtime hook had to be added to the exclusion list. The current design reaches
the same Think surface without either cost.

## History

- [rfc-durable-object-lifecycle.md](./rfc-durable-object-lifecycle.md) — vendored
the lifecycle and established `lifecycle.start()` as the explicit RPC contract
this record extends.
Loading
Loading