Skip to content

Facet broadcasts after the frame completes cost two root RPCs per message (getAgentByName on every broadcast) #2252

Description

@anuarhdz

Summary

When a facet broadcasts after the inbound frame that started its work has completed, every broadcast costs two RPC calls to the root: __unsafe_ensureInitialized (from getAgentByName) and _cf_broadcastToSubAgent. A streamed chat turn broadcasts once per stream chunk, so one long answer makes thousands of billed Durable Object requests to the root.

Seen on agents@0.22.0. The same code path is still there in 0.23.0.

Where

src/dynamic-agents/dynamic-agents.ts (0.23.0):

  • routeBroadcast() (~line 824) uses the live frame bridge only while the frame's AsyncLocalStorage context is active and no older operation is pending. Otherwise it calls (await this.rootAlarmOwner())._cf_broadcastToSubAgent(...).
  • rootAlarmOwner() (~line 216) calls getAgentByName(binding, root.name) every time. getAgentByName always awaits stub.__unsafe_ensureInitialized() before it returns the stub.

A chat turn keeps streaming after the user's frame is acknowledged, so for the whole model stream each chunk takes the root path. That means one ensureInitialized RPC and one broadcast RPC per chunk.

Evidence

Setup: a root Agent (the user's directory) with Think chat facets, connected through useAgent({ sub: [...] }). wrangler tail on a deployed Worker during about three streamed answers:

2656  HalleyDirectory  __unsafe_ensureInitialized
2611  HalleyDirectory  _cf_broadcastToSubAgent
  16  HalleyDirectory  _cf_setSubAgentConnectionState
  13  HalleyAgent      _cf_initAsFacet

The two counts track each other almost 1:1.

Impact

  • Each DO RPC method call is a billed request (pricing). The paid plan's 1M included requests cover only a few hundred long answers a month.
  • With Workers traces on, each call emits spans. Spans become billed observability events on October 1, 2026.
  • Each chunk waits on two serialized cross-object round trips. The broadcast tail makes them sequential.

Suggestion

  1. Resolve the root stub once per facet instance and reuse it in rootAlarmOwner(). By the time a facet is broadcasting, the root has already initialized it, so running ensureInitialized again on every call looks redundant. This alone would halve the calls.
  2. Optionally, coalesce facet broadcasts on the root path, for example by batching messages queued behind the same tail into a single _cf_broadcastToSubAgent call.

Happy to test a patch.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions