Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/cold-rpc-starts-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"agents": patch
"@cloudflare/think": patch
---

Initialize Agent and Think instances before native Durable Object RPC methods execute on a cold instance. Native RPC now performs normal PartyServer startup, so Agents addressed with `newUniqueId()` or `idFromString()` must first be bootstrapped with `setName()`; prefer name-based addressing with `idFromName()` or `getAgentByName()`.
167 changes: 123 additions & 44 deletions packages/agents/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1554,14 +1554,54 @@ export function getCurrentAgent<
* @returns A wrapped method that runs within the agent context
*/

// Native RPC initialization applies to the public application RPC surface.
// PartyServer methods initialize themselves or belong to its synchronous
// lifecycle/control plane, so derive that boundary from the installed Server
// prototype chain instead of duplicating upstream method names here.
const RPC_INITIALIZATION_CONTROL_METHODS = new Set<string>();
let serverPrototype: object | null = Server.prototype;
while (serverPrototype && serverPrototype !== Object.prototype) {
for (const methodName of Object.getOwnPropertyNames(serverPrototype)) {
RPC_INITIALIZATION_CONTROL_METHODS.add(methodName);
}
serverPrototype = Object.getPrototypeOf(serverPrototype);
}

for (const methodName of [
// Destruction deliberately bypasses startup when alarm() resumes teardown of
// a condemned Agent. Initializing here would recreate state immediately
// before destroy() erases it.
"destroy",
// Connection policy hooks make synchronous decisions inside framework entry
// points and must never return an initialization Promise.
"shouldConnectionBeReadonly",
"shouldSendProtocolMessages",
"isConnectionProtocolEnabled",
// Agent state hooks are local lifecycle callbacks, not application RPC.
"onStateChanged",
"onStateUpdate",
"validateStateChange"
]) {
RPC_INITIALIZATION_CONTROL_METHODS.add(methodName);
}

const RPC_INITIALIZATION_WRAPPERS = new WeakSet<object>();

type AgentContextMethodReturn<T extends (...args: never[]) => unknown> =
| ReturnType<T>
| Promise<Awaited<ReturnType<T>>>;

// oxlint-disable-next-line @typescript-eslint/no-explicit-any -- generic callable constraint
function withAgentContext<T extends (...args: any[]) => any>(
method: T
): (
this: Agent<Cloudflare.Env, unknown>,
...args: Parameters<T>
) => ReturnType<T> {
return function (...args: Parameters<T>): ReturnType<T> {
) => AgentContextMethodReturn<T> {
const wrappedMethod = function (
this: Agent<Cloudflare.Env, unknown>,
...args: Parameters<T>
): AgentContextMethodReturn<T> {
const { agent } = getCurrentAgent();

if (agent === this) {
Expand All @@ -1578,10 +1618,23 @@ function withAgentContext<T extends (...args: any[]) => any>(
email: undefined
},
() => {
return method.apply(this, args);
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)
);
}
);
};
RPC_INITIALIZATION_WRAPPERS.add(wrappedMethod);
return wrappedMethod;
}

/**
Expand Down Expand Up @@ -1609,6 +1662,13 @@ export class Agent<
State = unknown,
Props extends Record<string, unknown> = Record<string, unknown>
> extends Server<Env, Props> {
/** @internal Native RPC lifecycle state; armed after derived construction. */
protected _rpcInitializationState:
| "constructing"
| "pending"
| "starting"
| "started" = "constructing";

private _state = DEFAULT_STATE as State;
private _disposables = new DisposableStore();
private _destroyed = false;
Expand Down Expand Up @@ -2335,14 +2395,27 @@ export class Agent<
constructor(ctx: AgentContext, env: Env) {
super(ctx, env);

// Base constructors run before derived class fields and constructor bodies.
// Keep local calls synchronous during that phase, then arm native-RPC
// initialization in a microtask protected by blockConcurrencyWhile(). The
// runtime cannot dispatch an external event/RPC until this callback settles,
// while JavaScript guarantees the derived constructor finishes before the
// current stack reaches the awaited continuation.
this.ctx.blockConcurrencyWhile(async () => {
await Promise.resolve();
if (this._rpcInitializationState === "constructing") {
this._rpcInitializationState = "pending";
}
});

this.mcp = this._withAgentSpan(
"agent_initialization",
"initialization",
{},
(update) => {
if (!wrappedClasses.has(this.constructor)) {
// Auto-wrap custom methods with agent context
this._autoWrapCustomMethods();
this._autoWrapRpcMethods();
wrappedClasses.add(this.constructor);
}

Expand Down Expand Up @@ -2858,10 +2931,23 @@ export class Agent<
}
);
};
this.onStart = (props?: Props) =>
this._withAgentSpan("agent_start", "startup", {}, (update) =>
startAgent(props, update)
);
this.onStart = async (props?: Props) => {
const previousInitialization = this._rpcInitializationState;
this._rpcInitializationState = "starting";
try {
const result = await this._withAgentSpan(
"agent_start",
"startup",
{},
(update) => startAgent(props, update)
);
this._rpcInitializationState = "started";
return result;
} catch (error) {
this._rpcInitializationState = previousInitialization;
throw error;
}
};
}

/**
Expand Down Expand Up @@ -3565,64 +3651,57 @@ export class Agent<
}

/**
* Automatically wrap custom methods with agent context
* This ensures getCurrentAgent() works in all custom methods without decorators
* Wraps the public application RPC methods exposed by an Agent subclass with
* invocation context and cold-start initialization. Explicit PartyServer
* lifecycle/control-plane methods retain their dispatch semantics, while
* underscore-prefixed methods are reserved for framework internals.
*/
private _autoWrapCustomMethods() {
// Collect all methods from base prototypes (Agent and Server)
const basePrototypes = [Agent.prototype, Server.prototype];
const baseMethods = new Set<string>();
for (const baseProto of basePrototypes) {
let proto = baseProto;
while (proto && proto !== Object.prototype) {
const methodNames = Object.getOwnPropertyNames(proto);
for (const methodName of methodNames) {
baseMethods.add(methodName);
}
proto = Object.getPrototypeOf(proto);
}
}
// Get all methods from the current instance's prototype chain
private _autoWrapRpcMethods() {
// The nearest descriptor is authoritative. Record every name before
// classifying its descriptor so an inherited method can never replace a
// subclass getter or non-function property with the same name.
const discoveredNames = new Set<string>();
let proto = Object.getPrototypeOf(this);
let depth = 0;
while (proto && proto !== Object.prototype && depth < 10) {
const methodNames = Object.getOwnPropertyNames(proto);
for (const methodName of methodNames) {
const descriptor = Object.getOwnPropertyDescriptor(proto, methodName);
while (proto && proto !== Object.prototype) {
for (const methodName of Object.getOwnPropertyNames(proto)) {
if (discoveredNames.has(methodName)) continue;
discoveredNames.add(methodName);

// Skip if it's a private method, a base method, a getter, or not a function,
const descriptor = Object.getOwnPropertyDescriptor(proto, methodName);
if (
baseMethods.has(methodName) ||
RPC_INITIALIZATION_CONTROL_METHODS.has(methodName) ||
methodName.startsWith("_") ||
!descriptor ||
!!descriptor.get ||
!!descriptor.set ||
typeof descriptor.value !== "function"
) {
continue;
}

// Now, methodName is confirmed to be a custom method/function
// Wrap the custom method with context
const originalMethod = descriptor.value;
if (RPC_INITIALIZATION_WRAPPERS.has(originalMethod)) {
continue;
}

/* oxlint-disable @typescript-eslint/no-explicit-any -- dynamic method wrapping requires any */
const wrappedFunction = withAgentContext(
this[methodName as keyof this] as (...args: any[]) => any
originalMethod as (...args: any[]) => any
) as any;
/* oxlint-enable @typescript-eslint/no-explicit-any */

// if the method is callable, copy the metadata from the original method
if (this._isCallable(methodName)) {
callableMetadata.set(
wrappedFunction,
callableMetadata.get(this[methodName as keyof this] as Function)!
);
const metadata = callableMetadata.get(originalMethod);
if (metadata) {
callableMetadata.set(wrappedFunction, metadata);
}

// set the wrapped function on the prototype
this.constructor.prototype[methodName as keyof this] = wrappedFunction;
Object.defineProperty(this.constructor.prototype, methodName, {
...descriptor,
value: wrappedFunction
});
}

proto = Object.getPrototypeOf(proto);
depth++;
}
}

Expand Down
15 changes: 8 additions & 7 deletions packages/agents/src/tests/agents-core-eviction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,12 @@ describe("Agent recovery after forced Durable Object eviction", () => {

await evictDurableObject(stub);

expect(await agent.getExecutionLog()).not.toContain("executed:warm");
expect(await agent.getRunningFiberCount()).toBe(1);

await agent.triggerRecoveryCheck();

// The first native RPC initializes the reconstructed Agent and runs startup
// recovery before returning the recovered fibers.
const recovered =
(await agent.getRecoveredFibers()) as unknown as FiberRecoveryContext[];

expect(await agent.getExecutionLog()).not.toContain("executed:warm");
expect(recovered).toEqual([
expect.objectContaining({
id: "evicted-fiber",
Expand All @@ -126,7 +125,7 @@ describe("Agent recovery after forced Durable Object eviction", () => {
expect(await agent.getRunningFiberCount()).toBe(0);
});

it("applies managed-fiber recovery on a fresh instance", async () => {
it("continues managed-fiber recovery through the first post-wake cycle", async () => {
const stub = await getAgentByName(
env.TestRunFiberAgent,
uniqueName("evict-managed-fiber")
Expand All @@ -141,7 +140,9 @@ describe("Agent recovery after forced Durable Object eviction", () => {

await evictDurableObject(stub);

expect(await agent.getRecoveredFibers()).toEqual([]);
// This first post-eviction RPC runs startup recovery before the method,
// then drives the follow-up housekeeping cycle that completes the managed
// recovery callback.
await agent.simulateAlarmCycle();

const recovered =
Expand Down
1 change: 1 addition & 0 deletions packages/agents/src/tests/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export {
} from "./session";
export { TestMultiSessionAgent } from "./multi-session";
export { TestWaitConnectionsAgent } from "./wait-connections";
export { TestNativeRpcAgent } from "./native-rpc";
export { SpikeSubParent, SpikeSubChild } from "./spike-sub-agent-routing";
export {
TestSubAgentParent,
Expand Down
66 changes: 66 additions & 0 deletions packages/agents/src/tests/agents/native-rpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Agent } from "../../index.ts";
import type { AgentContext } from "../../index.ts";

class NativeRpcBaseAgent extends Agent {
shadowedMethod(): string {
return "inherited method";
}
}

export class TestNativeRpcAgent extends NativeRpcBaseAgent {
private constructionFinished = false;
private fieldInitializerObservation = this.observeConstruction("field");
private constructorObservation: string;
private ready = false;

constructor(ctx: AgentContext, env: Cloudflare.Env) {
super(ctx, env);
this.constructorObservation = this.observeConstruction("constructor");
this.constructionFinished = true;
}

private observeConstruction(location: string): string {
return `${location}:${this.constructionFinished ? "finished" : "constructing"}`;
}

override async onStart(): Promise<void> {
if (await this.ctx.storage.get<boolean>("fail_if_started")) {
throw new Error("condemned agent must not start");
}

const starts =
(await this.ctx.storage.get<number>("test_start_count")) ?? 0;
await this.ctx.storage.put("test_start_count", starts + 1);
this.ready = true;
}

async applicationRpc(): Promise<{
name: string;
ready: boolean;
startCount: number;
fieldInitializerObservation: string;
constructorObservation: string;
shadowedValue: string;
}> {
return {
name: this.name,
ready: this.ready,
startCount: (await this.ctx.storage.get<number>("test_start_count")) ?? 0,
fieldInitializerObservation: this.fieldInitializerObservation,
constructorObservation: this.constructorObservation,
shadowedValue: (this as unknown as { shadowedMethod: string })
.shadowedMethod
};
}
}

// The nearest descriptor must remain authoritative during RPC wrapper discovery.
// Defining this after the class avoids TypeScript treating the intentional
// method-to-getter shadow as an invalid override.
Object.defineProperty(TestNativeRpcAgent.prototype, "shadowedMethod", {
configurable: true,
enumerable: false,
get() {
return "nearest getter";
}
});
4 changes: 3 additions & 1 deletion packages/agents/src/tests/mcp/create-oauth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ describe("createMcpOAuthProvider", () => {
});

it("should resolve PKCE verifiers by OAuth callback state", async () => {
const agentId = env.TestOAuthAgent.newUniqueId();
const agentId = env.TestOAuthAgent.idFromName(
`pkce-state-correlation-${crypto.randomUUID()}`
);
const agentStub = env.TestOAuthAgent.get(agentId);

const result = await agentStub.testPkceVerifierStateCorrelation();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { describe, expect, it } from "vitest";
// reproduced with an in-memory mock).
describe("DurableObjectOAuthClientProvider PKCE binding", () => {
function agent() {
const id = env.TestOAuthAgent.newUniqueId();
const id = env.TestOAuthAgent.idFromName(
`oauth-provider-${crypto.randomUUID()}`
);
return env.TestOAuthAgent.get(id);
}

Expand Down
Loading
Loading