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
5 changes: 5 additions & 0 deletions .changeset/fresh-facets-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": patch
---

Fix nested sub-agent bootstrap so facet parents do not need to be bound as top-level Durable Object namespaces.
6 changes: 6 additions & 0 deletions design/sub-agent-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ this.parentPath;
`parentPath` is **root-first**, so the direct parent is always the **last**
entry, not the first.

The SDK also passes an explicit `id` to `ctx.facets.get()` so PartyServer can
resolve `this.name` from `ctx.id.name` inside the facet. That ID is derived from
the top-level root/supervisor namespace, not the immediate parent, because the
immediate parent may itself be a facet and is not expected to expose namespace
helpers such as `idFromName`.

## `parentAgent(Cls)`

`Agent#parentAgent(Cls)` is the one-hop inverse of `subAgent(Cls, name)`:
Expand Down
9 changes: 2 additions & 7 deletions examples/multi-ai-chat/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types env.d.ts --include-runtime false` (hash: auto-regenerable)
// Generated by Wrangler by running `wrangler types env.d.ts --include-runtime false` (hash: 76a72d2f3fd341401da5fd76b1b416fc)
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./src/server");
durableNamespaces: "Inbox" | "Chat";
durableNamespaces: "Inbox";
}
interface Env {
AI: Ai;
// `Inbox` is the only top-level DO binding. `Chat` is a facet of
// Inbox — reached via `this.subAgent(Chat, id)` inside Inbox, or
// via `/agents/inbox/{user}/sub/chat/{id}` from a client. `Chat`
// still needs to live in `new_sqlite_classes` so the runtime
// knows how to construct it, but not as a namespace binding.
Inbox: DurableObjectNamespace<import("./src/server").Inbox>;
}
}
Expand Down
2 changes: 1 addition & 1 deletion examples/multi-ai-chat/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"main": "src/server.ts",
"migrations": [
{
"new_sqlite_classes": ["Inbox", "Chat"],
"new_sqlite_classes": ["Inbox"],
"tag": "v1"
}
],
Expand Down
68 changes: 31 additions & 37 deletions packages/agents/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,21 +265,15 @@ export class SqlError extends Error {
interface FacetCapableCtx {
facets: DurableObjectFacets;
/**
* Worker exports keyed by class export name. workerd's runtime
* contract: any class registered via `migrations.new_sqlite_classes`
* (or `migrations.new_classes`) — including facet-only classes
* that have NO entry in `durable_objects.bindings` — is exposed
* here as BOTH a `DurableObjectClass` (usable as
* `FacetStartupOptions.class`) AND a `DurableObjectNamespace`
* (usable for `idFromName`/`getByName`). The intersection is what
* makes `ctx.exports[OuterSubAgent].idFromName(...)` work from
* inside a nested facet bootstrap, even though `OuterSubAgent`
* isn't bound. Runtime lookups can still return `undefined` for
* unregistered class names; callers must null-check.
* Worker exports keyed by class export name. For facet creation, the
* runtime only needs the exported Durable Object class. Top-level
* Durable Object bindings may also expose namespace helpers here, but
* facet-only classes do not need to.
*/
exports: Record<
string,
(DurableObjectClass & DurableObjectNamespace) | undefined
| (DurableObjectClass & Partial<Pick<DurableObjectNamespace, "idFromName">>)
| undefined
>;
}

Expand Down Expand Up @@ -5548,11 +5542,11 @@ export class Agent<
*
* The facet's name (and `this.name` getter) is handled entirely by
* partyserver via `ctx.id.name`, which is populated because the
* parent passed an explicit `id: parentNs.idFromName(name)` to
* parent passed an explicit named Durable Object id to
* `ctx.facets.get()` — see {@link _cf_resolveSubAgent}. No
* `setName()` call or `__ps_name` storage write is needed; the
* facet's name survives cold wake automatically because the
* factory re-runs and `idFromName` is deterministic.
* facet's name survives cold wake automatically because the factory
* re-runs and `idFromName` is deterministic.
*
* @internal Called by {@link subAgent}.
*/
Expand All @@ -5561,9 +5555,9 @@ export class Agent<
parentPath: ReadonlyArray<{ className: string; name: string }> = []
): Promise<void> {
// Defense in depth: the parent is supposed to construct the
// facet with `id: parentNs.idFromName(name)` via
// `_cf_resolveSubAgent`, which makes `this.name` resolve to
// `name` automatically through partyserver's `ctx.id.name`. If
// facet with a named Durable Object id via `_cf_resolveSubAgent`,
// which makes `this.name` resolve to `name` automatically
// through partyserver's `ctx.id.name`. If
// it didn't (e.g. someone bypassed `_cf_resolveSubAgent`, or
// the parent's id construction has a bug), `this.name` would
// silently report the parent's name instead of the facet's
Expand Down Expand Up @@ -6672,23 +6666,23 @@ export class Agent<
// Composite key: class name + NUL + facet name, so two different
// classes can share the same user-facing name.
const facetKey = `${className}\0${name}`;
// Pass an explicit `id` in FacetStartupOptions so the facet has
// its own `ctx.id.name === name` (not the parent's name).
// Without this, facets inherit the parent DO's `ctx.id` and
// `this.name` on the facet would silently return the parent's
// name. See:
// Pass an explicit named `id` in FacetStartupOptions so the
// facet has its own `ctx.id.name === name` (not the parent's
// name). Without this, facets inherit the parent DO's `ctx.id`
// and `this.name` on the facet would silently return the
// parent's name. See:
// https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/
//
// The id is constructed from the parent's own bound namespace,
// which is always present in `ctx.exports` because the parent
// Agent class is bound as a DO. Any bound DurableObjectNamespace
// would work — the id is opaque + a name; nothing routes
// through the namespace at runtime for facets. We use the
// parent's because it's guaranteed available without extra
// env-binding lookups.
const parentClassName = (this.constructor as { name: string }).name;
const parentNs = ctx.exports[parentClassName];
if (!parentNs?.idFromName) {
// For nested facets, the immediate parent is itself facet-only
// and is not expected to expose namespace helpers. Use the root
// supervisor namespace instead; the id is opaque for facet
// routing, but `idFromName(name)` gives PartyServer a stable
// `ctx.id.name`.
const rootClassName =
this._parentPath[0]?.className ??
(this.constructor as { name: string }).name;
const rootNs = ctx.exports[rootClassName];
if (!rootNs?.idFromName) {
// Minification is the most common cause of this error in
// production builds: aggressive bundlers rewrite class
// identifiers to short ids, so `this.constructor.name`
Expand All @@ -6701,15 +6695,15 @@ export class Agent<
// `_a`, `_ab`, `_a1`, `__a`). Real class names like
// `MyAgent` or `_UnboundParent` start with an uppercase
// letter and won't match.
const looksMinified = /^_*[a-z][a-z0-9]{0,2}$/.test(parentClassName);
const looksMinified = /^_*[a-z][a-z0-9]{0,2}$/.test(rootClassName);
const minificationHint = looksMinified
? ` The class name "${parentClassName}" looks minified — make sure your bundler preserves class names (e.g. esbuild's \`keepNames: true\`).`
? ` The class name "${rootClassName}" looks minified — make sure your bundler preserves class names (e.g. esbuild's \`keepNames: true\`).`
: "";
throw new Error(
`Sub-agent bootstrap requires the parent class "${parentClassName}" to be bound as a Durable Object namespace, but ctx.exports["${parentClassName}"] is missing or doesn't expose idFromName.${minificationHint} Make sure the parent agent class is registered in your wrangler.jsonc durable_objects.bindings under its class name.`
`Sub-agent bootstrap requires the root agent class "${rootClassName}" to be available as a Durable Object namespace, but ctx.exports["${rootClassName}"] is missing or doesn't expose idFromName.${minificationHint} Make sure the root agent class is exported under that class name and registered in your wrangler.jsonc durable_objects.bindings.`
);
}
const facetId = parentNs.idFromName(name);
const facetId = rootNs.idFromName(name);
const stub = ctx.facets.get(facetKey, () => ({
class: Cls as DurableObjectClass,
id: facetId
Expand Down
61 changes: 42 additions & 19 deletions packages/agents/src/tests/agents/sub-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,32 @@ export class InnerSubAgent extends Agent {
}

export class OuterSubAgent extends Agent {
async spawnInnerWithOwnNamespaceHelperHidden(
innerName: string
): Promise<string> {
const exports = (
this.ctx as unknown as {
exports?: Record<string, { idFromName?: unknown } | undefined>;
}
).exports;
const ownExport = exports?.OuterSubAgent;
const originalIdFromName = ownExport?.idFromName;

try {
if (ownExport) {
ownExport.idFromName = undefined;
}
await this.subAgent(InnerSubAgent, innerName);
return "";
} catch (e) {
return e instanceof Error ? e.message : String(e);
} finally {
if (ownExport) {
ownExport.idFromName = originalIdFromName;
}
}
}

async getInnerValue(innerName: string, key: string): Promise<string | null> {
const inner = await this.subAgent(InnerSubAgent, innerName);
return inner.getVal(key);
Expand Down Expand Up @@ -1272,6 +1298,14 @@ export class TestSubAgentParent extends Agent {
await outer.spawnInner(innerName);
}

async nestedSpawnWithFacetParentNamespaceHidden(
outerName: string,
innerName: string
): Promise<string> {
const outer = await this.subAgent(OuterSubAgent, outerName);
return outer.spawnInnerWithOwnNamespaceHelperHidden(innerName);
}

async insertNestedInterruptedFiber(
outerName: string,
innerName: string,
Expand Down Expand Up @@ -1763,24 +1797,14 @@ export class HookingSubAgentParent extends Agent {
}
}

// ── Unbound-parent fixtures ─────────────────────────────────────────
// ── Root export-name fixtures ───────────────────────────────────────
//
// `_cf_resolveSubAgent` looks up the parent's namespace via
// `ctx.exports[this.constructor.name]`. If `this.constructor.name`
// doesn't match a key in `ctx.exports` (e.g. minification rewrote
// the class identifier, or the class was exported under a different
// name from its declaration), the throw fires with a helpful error.
//
// We exercise this path via two fixture parents whose class
// identifiers (left of the `as` in the export rename below)
// deliberately don't match their export names. `this.constructor.name`
// for instances of these classes is the original class identifier,
// but `ctx.exports[<class identifier>]` is undefined because the
// worker's exports register the class under the export alias.

/** Class identifier `_UnboundParent` (could happen if a bundler kept
* the leading underscore but `ctx.exports` indexes by the export
* alias). Not "minified-looking" enough to trigger the hint. */
// These root agents deliberately have class identifiers that differ
// from their export names. Sub-agent bootstrap still needs the root
// namespace to construct named facet ids, so these fixtures exercise
// the descriptive error path.

/** Class identifier `_UnboundParent`, exported as `TestUnboundParentAgent`. */
class _UnboundParent extends Agent {
async tryToSpawn(name: string): Promise<string> {
try {
Expand All @@ -1793,8 +1817,7 @@ class _UnboundParent extends Agent {
}
export { _UnboundParent as TestUnboundParentAgent };

/** Class identifier `_a` — looks minified. The error message should
* include the minification hint. */
/** Class identifier `_a`, exported as `TestMinifiedNameParentAgent`. */
class _a extends Agent {
async tryToSpawn(name: string): Promise<string> {
try {
Expand Down
37 changes: 23 additions & 14 deletions packages/agents/src/tests/sub-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,31 +165,28 @@ describe("SubAgent", () => {
expect(error).toMatch(/not found in worker exports/);
});

it("should throw descriptive error when the parent class is exported under a different name than its declaration", async () => {
// The parent's class identifier is `_UnboundParent` but it's
// exported as `TestUnboundParentAgent`. So `this.constructor.name`
// inside an instance is `_UnboundParent`, but `ctx.exports` is
// keyed by the export name (`TestUnboundParentAgent`). The
// ctx.exports[parentClassName] lookup fails, and we expect a
// helpful error pointing at the binding requirement.
it("should throw descriptive error when the root class is exported under a different name than its declaration", async () => {
// Top-level roots still need a namespace for named facet IDs. If
// the root class identifier and export/binding name differ, the
// framework cannot find that namespace by constructor name.
const parentName = uniqueName();
const childName = uniqueName();
const agent = await getAgentByName(env.TestUnboundParentAgent, parentName);

const error = await agent.tryToSpawn(childName);
expect(error).toMatch(
/Sub-agent bootstrap requires the parent class "_UnboundParent" to be bound/
/Sub-agent bootstrap requires the root agent class "_UnboundParent" to be available/
);
expect(error).toMatch(/wrangler\.jsonc durable_objects\.bindings/);
// Class identifier doesn't look minified — no minification hint.
expect(error).not.toMatch(/looks minified/);
});

it("should hint at minification when the parent class name looks minified", async () => {
// Same scenario, but the parent's class identifier is `_a` —
// matches the minification heuristic. The error message should
// include the bundler hint so users with minified production
// builds get a helpful pointer.
it("should hint at minification when the root class name looks minified", async () => {
// Same scenario, but the root class identifier is `_a` — matches
// the minification heuristic. The error message should include
// the bundler hint so users with minified production builds get a
// helpful pointer.
const parentName = uniqueName();
const childName = uniqueName();
const agent = await getAgentByName(
Expand All @@ -198,7 +195,7 @@ describe("SubAgent", () => {
);

const error = await agent.tryToSpawn(childName);
expect(error).toMatch(/parent class "_a" to be bound/);
expect(error).toMatch(/root agent class "_a" to be available/);
expect(error).toMatch(/looks minified/);
expect(error).toMatch(/keepNames/);
});
Expand Down Expand Up @@ -343,6 +340,18 @@ describe("SubAgent", () => {
const result = await agent.nestedPing("outer-1");
expect(result).toBe("outer-pong");
});

it("should not require the immediate facet parent to expose namespace helpers", async () => {
const name = uniqueName();
const agent = await getAgentByName(env.TestSubAgentParent, name);

const error = await agent.nestedSpawnWithFacetParentNamespaceHidden(
"outer-1",
"inner-1"
);

expect(error).toBe("");
});
});

it("should schedule delayed callbacks from a sub-agent and execute inside the child", async () => {
Expand Down
Loading