feat(agents): dynamic agents as a real Lifecycle capability - #2250
feat(agents): dynamic agents as a real Lifecycle capability#2250mattzcarey wants to merge 1 commit into
Conversation
DynamicAgents (agents/dynamic-agents) installs on any plain Durable Object with Lifecycle.install(this).use(new DynamicAgents()) plus a one-line _cf_lifecycle routing aperture. It spawns, supervises, and addresses child Durable Objects, forwards /sub/ HTTP and WebSocket upgrades after an onBeforeChild gate, owns keep-alive holds and fiber leases as its own jobs, and provides the route transport Scheduler and Tasks use to reach the root. Lifecycle gains the primitives this needed: bootstrap envelopes delivered before startup, a capability-provided route transport with local inbound delivery queued during startup, routes.retire() fanning out to onRouteRetired (Scheduler and Tasks implement it), and narrow facets, exports, object, and waitUntil services. WebSockets accepts bridged:* route messages so a child's sockets, which live on the root, reach the child's own handlers and getConnections(). Agent installs the capability itself and drops its facet host port, the DynamicAgents facade, and every internal _cf_* facet RPC method; only _cf_lifecycle remains as the routing aperture. Sockets accepted by the previous release keep reaching their child. Adds examples/next/dynamic-agents-plain, plain-DO fixtures and suites, Lifecycle routing and WebSockets bridging tests, and docs.
🦋 Changeset detectedLatest commit: a32dd58 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🟡 agents import sizesMeasured 340 runtime imports as minified bundles. The primary size is gzip; raw minified size is included for diagnosis. An existing import growing by more than 10% is marked red. This report is informational.
Compared Changed imports (116)
All 340 current runtime imports
Reported by agent-think[bot]. |
| const { 0: client, 1: server } = new WebSocketPair(); | ||
| // `||`, not `??`: an empty `?_pk=` value must fall back to a generated id. | ||
| const id = new URL(request.url).searchParams.get("_pk") || nanoid(); | ||
| const record = acceptOwnedSocket(this.lifecycle.sockets, server, { | ||
| id, | ||
| outer: request.headers.get(SUB_AGENT_OUTER_URL_HEADER) ?? request.url, | ||
| tags: [id], | ||
| state: null | ||
| }); |
There was a problem hiding this comment.
🔴 Failed upgrades leak accepted sockets
When child resolution or connect delivery fails, onWebSocketUpgrade leaves the accepted socket open. Repeated failed upgrades accumulate ownerless hibernating sockets.
Learn more
The capability accepts the server half into the Durable Object before resolving the child and delivering ws:connect. Either later operation can reject, including when the child has no WebSockets capability or its connection setup throws. Lifecycle then creates a separate error WebSocket response, while the accepted server socket remains registered in hibernation storage.
Example: A request upgrades to a valid dynamic child that does not install WebSockets. acceptOwnedSocket registers socket A, child delivery rejects, and Lifecycle returns error socket B. Socket A has no returned client endpoint and remains attached to the parent.
Recommended fix: Wrap all work after acceptOwnedSocket in try/catch. Close the accepted record on failure before rethrowing, using an appropriate setup-failure close code and guarding close errors.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (capability.provideRouteTransport) { | ||
| if (lifecycleRouteTransports.has(this)) { | ||
| throw new Error("Lifecycle already has a route transport"); | ||
| } |
There was a problem hiding this comment.
🟡 Rejected transport stays installed
When a second provider is used, use() inserts and binds it before throwing. Catching the error leaves the rejected capability active.
Learn more
use() mutates #capabilities, #fallbacks, and capability service bindings before checking whether a route transport already exists. The thrown installation error therefore does not reject the capability atomically. A caller that handles the error gets a Lifecycle whose dispatch chain differs from the successful use() calls it observed.
Example: A host installs transport A, catches the error from installing transport B, then starts Lifecycle. Transport B's onStart, request hooks, and route-retirement hook still run despite its rejected installation.
Recommended fix: Check capability.provideRouteTransport and the existing transport before mutating the capability list, fallback set, or service binding. Only bind and register the transport after every validation passes.
Was this helpful? React with 👍 or 👎 to provide feedback.
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
What this does
After #2193, dynamic agents (facets) were still an Agent-owned facade:
DynamicAgentsInternaltook a ~40-memberDynamicAgentHostPort, Agent passedthisas the host and kept ~20_cf_*RPC entry points, and four hot paths bypassed the capability runner on purpose. Nothing could install dynamic agents on a plain Durable Object.This PR makes
DynamicAgentsa real Lifecycle capability, migrates Agent (and Think / AIChatAgent through it) onto it with no observable behavior change, and ships a plain-DO example.1.
DynamicAgentsatagents/dynamic-agentsget/abort/delete/has/list; identity asisChild/name/parentPath/selfPath; from inside a child,parent(Cls),deleteSelf(),broadcast(),keepAlive(),holdLease()/releaseLease()./sub/{class}/{name}/...HTTP requests (onRequest) and WebSocket upgrades (onWebSocketUpgrade) after theonBeforeChildgate, so the runner bypasses are gone. Options are policy only:onBeforeChild,checkLeases,keepAliveIntervalMs.cf_agents_sub_agentsregistry, thecf_agents_facet_runslease index, root-held keep-alive tokens, and its ownkeep-alive/lease-sweepLifecycle jobs.provideRouteTransport) that Scheduler and Tasks use to reach the root, walking the tree one hop at a time over the hosts' single_cf_lifecycleaperture. All cross-object traffic is a discriminatedDynamicAgentRouteMessageunion; bridges ride by reference asRpcTargets.WebSocketscapability, whose handlers,getConnections(), andconnection.setState()see them like any other connection. Sockets accepted by the previous release (WebSockets__pk+__user._cf_subAgentOuterUrl) are recognized and still served.Every wire- and storage-visible identifier is unchanged: tables, the three identity storage keys, the legacy connection flags, the
x-cf-agents-subagent-urlheader, path-v2 identity strings, route-key format, the${class}\0${name}facet key, and the pinned console error strings.2. Lifecycle primitives (the special cases became generic)
LifecycleRouteEnvelope.bootstrapdelivers a message before startup (LifecycleRouteContext.started === false); the capability writes what startup must observe and callslifecycle.ready(). This is how a fresh child learns its identity before itsonStart.DurableObjectCapability.provideRouteTransport(inbound); one provider per Lifecycle.inbound.deliver()is local delivery, queued while starting and flushed inside the input gate.setLifecycleRouteTransportis deleted.LifecycleRoutes.retire()fans out toonRouteRetired; Scheduler and Tasks implement it and lose__DO_NOT_USE_WILL_BREAK__cleanupRoutePrefix.facets,exports,object,waitUntilonLifecycleServices.DurableObjectStateis still never handed to a capability.bridged:sync/bridged:connect/bridged:message/bridged:closeon its route; a routed (child) Lifecycle owns no platform sockets, so there the bridged connections are the only ones.3. Agent migration
Agent installs
new DynamicAgents({ onBeforeChild: this.onBeforeSubAgent, checkLeases, keepAliveIntervalMs })first in itsuse()chain and reads identity from it. Deleted: the host port, theDynamicAgentsfacade class,_isFacet/_facetName/_parentPath, the/sub/branch ofAgent.fetch, the forwarding branches in theonConnect/onMessage/onClosewrappers,_restoreAgentFacetContext,_runFacetInitInvocation, the facet term of_nextHousekeepingWakeMs, and every internal_cf_*facet RPC method (_cf_initAsFacet,_cf_invokeAgentPath,_cf_invokeSubAgent, keep-alive, lease, connection, and WebSocket forwarding entry points)._cf_lifecycleis the only routing aperture.index.tsloses 534 net lines.subAgent()-family methods,/sub/URLs,useAgent({ sub }),onBeforeSubAgent,parentAgent(), andgetSubAgentByNamekeep working. Two protected one-liners (_cf_requestTargetsSubAgent,_cf_connectionTargetsSubAgent) stay, deprecated, because published Think / ai-chat releases call them in everyonConnect; their now-dead calls are removed from Think and AIChatAgent in this PR.Behavior deltas:
onBeforeSubAgentruns after Lifecycle startup and inside host context; intermediate hops no longer register deep-targeted connections; facet keep-alive holds live on the capability rather than_keepAliveRefs; thex-agents-lifecycle-propsheader no longer leaks into/sub/children; therestore_agent_statestartup span is gone.Install order is load-bearing and documented: DynamicAgents first, before capabilities that route to children and before WebSockets. The capability throws at startup, with the one-line snippet, when a host lacks
_cf_lifecycle.4. Example, tests, docs
examples/next/dynamic-agents-plain: aWorkspacespawnsNotebookchildren on plain DOs, forwards HTTP, bridges WebSockets, gates by registry, with tests.tests/capabilities/dynamic-agents.tsfixtures andtests/dynamic-agents/*suites (spawn and identity, HTTP forwarding and the gate, bridged WebSockets including legacy sockets, teardown and retirement, keep-alive and leases, child schedules through the root); Lifecycle routing primitive tests; a WebSockets bridged-connection probe;tests-d/dynamic-agents-export.test-d.ts._cf_*overrides to_cf_lifecycleoverrides keyed onpayload.type.docs/agents/sub-agents.mdgains "On a plain Durable Object";docs/agents/lifecycle.mddocuments the transport, bootstrap envelopes, retirement, the new services, and bridged connections. Minor changeset foragents.Verification
packages/agentsworkers project: 1908 tests / 124 files green (plus the 30 facet-related files rerun after the shim removal: 386 tests)@cloudflare/think: 896 tests;@cloudflare/ai-chatworkers: 653 testsexamples/next/dynamic-agents-plain: 4 testspnpm run typecheck(121 projects),oxfmt --check .,oxlint,sherif,check:exportsall cleanNot in this PR
No migration of applications off facet-backed chat sessions; the many-chats topology stays documentation-only (#2193). Facet fault injection in tests still goes through
_cf_lifecycleoverrides rather than a public seam.