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
9 changes: 9 additions & 0 deletions .changeset/dynamic-agents-capability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"agents": minor
---

Dynamic agents are now a real Lifecycle capability. `DynamicAgents` from `agents/dynamic-agents` installs on any plain Durable Object with `Lifecycle.install(this).use(new DynamicAgents())` plus a one-line `_cf_lifecycle(envelope) { return this.lifecycle.route(envelope); }` routing aperture, and spawns, supervises, and addresses child Durable Objects (facets) with `get`/`abort`/`delete`/`has`/`list`, `keepAlive`, `holdLease`/`releaseLease`, and `broadcast`. It forwards `/sub/{class}/{name}/...` HTTP requests and WebSocket upgrades to the child after an `onBeforeChild` gate; a child's sockets stay on the parent and are bridged into the child's `WebSockets` capability, whose handlers and `getConnections()` see them like any other connection.

`Agent` installs the capability itself (`this.dynamicAgents`), so existing `subAgent()`-family calls, `/sub/` URLs, `useAgent({ sub })`, `onBeforeSubAgent`, and `parentAgent()` keep working, and sockets accepted by the previous release keep reaching their child. The internal `_cf_*` facet RPC methods on `Agent` (`_cf_initAsFacet`, `_cf_invokeAgentPath`, `_cf_invokeSubAgent`, the facet keep-alive, lease, connection, and WebSocket forwarding entry points) are removed; `_cf_lifecycle` is the only routing aperture. `onBeforeSubAgent` now runs after Lifecycle startup, inside host context.

Lifecycle gains the primitives this needed: `bootstrap` envelopes delivered before startup, a capability-provided route transport (`provideRouteTransport`) with local inbound delivery queued during startup, `routes.retire()` fanning out to an `onRouteRetired` hook (Scheduler and Tasks implement it), and narrow `facets`, `exports`, `object`, and `waitUntil` services. `WebSockets` accepts `bridged:*` route messages for connections another object owns.
48 changes: 40 additions & 8 deletions docs/agents/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,14 +238,37 @@ when delivery is part of the durable business operation.

Every `LifecycleCapability` also receives `lifecycle.routes`. `toRoot()` routes
a message to the matching capability ID on the root Lifecycle; `to(address, …)`
routes to another addressed Lifecycle. Lifecycle owns the generic envelope and
dispatch. A host with child objects supplies the transport internally.

Agent uses this for facet schedules: Scheduler sends owner-scoped CRUD to the
root Scheduler and routes due callbacks back to the matching facet Scheduler.
Facet schedules live as jobs in the root's queue. Scheduler does not
implement facet traversal, and Agent exposes only one internal generic Lifecycle
route aperture.
routes to another addressed Lifecycle; `retire(retirement)` announces that a
routed subtree no longer exists, and every installed capability's
`onRouteRetired` drops the durable work it mirrors for those owners. Lifecycle
owns the generic envelope and dispatch.

The transport is provided by a capability: at most one installed capability
implements `provideRouteTransport(inbound)`, returning the object's address and
`toRoot`/`to` senders. `DynamicAgents` is that capability — it walks the
parent/child tree one hop at a time over the hosts' `_cf_lifecycle` aperture,
the one native-RPC entry point routed capabilities need
(`_cf_lifecycle(envelope) { return this.lifecycle.route(envelope); }`). The
`inbound.deliver()` side delivers envelopes locally; envelopes handed over
while startup runs wait, in order, until it completes, so a capability that
starts early can address one that starts later.

An envelope marked `bootstrap: true` reaches its capability before the
Lifecycle has started (`context.started === false`); the capability writes
whatever startup must observe — a child's identity, for example — and starts
the object itself with `lifecycle.ready()`. Once started, a bootstrap envelope
is an ordinary route.

Scheduler and Tasks use routing for children: a child's Scheduler sends
owner-scoped CRUD to the root Scheduler, which routes due callbacks back to the
matching child. Child schedules live as jobs in the root's queue. Neither
capability implements traversal; both implement `onRouteRetired`.

Capabilities also receive narrow platform services alongside storage and
sockets: `facets` (`ctx.facets`, for colocated children), `exports` (class
lookup and loopback namespaces from `ctx.exports`), `object` (the routed name,
class name, and identity comparison), and `waitUntil`. The whole
`DurableObjectState` is never handed to a capability.

## Explicit disposal

Expand Down Expand Up @@ -342,6 +365,15 @@ object, its constructor and lifecycle startup run again before `onMessage`.
State needed after a wake must be stored durably or through
`connection.setState()`. There is no non-hibernating mode.

A socket another object owns can be bridged in: the owner's capability sends
`bridged:connect` / `bridged:message` / `bridged:close` messages to this
capability's route (`lifecycle.route({ capability: "websockets", … })`) with a
link for the operations that must travel back, and the capability presents the
socket as a connection the handlers and `getConnections()` see like any other.
This is how a dynamic agent's sockets, which live on its parent, reach the
child's handlers. A routed (child) Lifecycle owns no platform sockets, so there
the bridged connections are the only ones.

## Native RPC

Native Durable Object RPC does not pass through `fetch`. An RPC method that
Expand Down
68 changes: 65 additions & 3 deletions docs/agents/sub-agents.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Dynamic agents (facets)

Dynamic agents are child Durable Objects **colocated under and supervised by** a parent agent, built on the runtime's facet primitive. Each child runs in its **own isolate** with its **own SQLite database**, but lives inside the parent's Durable Object: the parent spawns it, can abort or delete it, and is the only way to reach it. Inside an agent they are typed RPC stubs reached via `this.dynamicAgents`; clients reach one directly via a nested URL.
Dynamic agents are child Durable Objects **colocated under and supervised by** a parent, built on the runtime's facet primitive. Each child runs in its **own isolate** with its **own SQLite database**, but lives inside the parent's Durable Object: the parent spawns it, can abort or delete it, and is the only way to reach it. The `DynamicAgents` capability from `agents/dynamic-agents` provides them to any Lifecycle Object; inside an `Agent` it is installed already and reached via `this.dynamicAgents`. Children are typed RPC stubs; clients reach one directly via a nested URL.

Use dynamic agents for code whose **class or lifecycle the parent owns**: dynamically-loaded or AI-generated code that has no wrangler binding, per-run tool agents, sandboxed components that need isolated storage plus supervised abort/restart. That is what the runtime built facets for.

Expand Down Expand Up @@ -142,9 +142,70 @@ For child workflow origins, `AgentWorkflow.agent` is RPC-only. Use it to call Ag

Dynamic agents know who their parent is via `this.parentPath` (root-first ancestor chain) and `this.parentAgent(ParentClass)` (typed stub). A child with no parent (top-level agent) has `parentPath === []`.

## On a plain Durable Object

`DynamicAgents` is a Lifecycle capability. Install it on any plain `DurableObject`, add the one-line routing aperture, and spawn children of any other Lifecycle Object class:

```typescript
import { DurableObject } from "cloudflare:workers";
import { DynamicAgents } from "agents/dynamic-agents";
import { Lifecycle, type LifecycleRouteEnvelope } from "agents/lifecycle";
import { WebSockets } from "agents/websockets";

export class Workspace extends DurableObject<Env> {
readonly children = new DynamicAgents({
// Gate every request bound for a child (like `onBeforeSubAgent`).
onBeforeChild: (request, child) =>
this.children.has(Notebook, child.name)
? undefined
: new Response("No such notebook", { status: 404 })
});
readonly lifecycle = Lifecycle.install(this).use(this.children);

// The native-RPC aperture routed capabilities travel through.
_cf_lifecycle(envelope: LifecycleRouteEnvelope) {
return this.lifecycle.route(envelope);
}

async onRequest() {
const notebook = await this.children.get(Notebook, "todo");
return Response.json({ notes: await notebook.listNotes() });
}
}

export class Notebook extends DurableObject<Env> {
readonly children = new DynamicAgents();
readonly webSockets = new WebSockets({ handlers: { onMessage } });
readonly lifecycle = Lifecycle.install(this)
.use(this.children)
.use(this.webSockets, { fallback: true });

_cf_lifecycle(envelope: LifecycleRouteEnvelope) {
return this.lifecycle.route(envelope);
}

listNotes() {
/* the child's own SQLite */
}
}
```

Install order is load-bearing: `DynamicAgents` first, before capabilities that route to children (`Scheduler`, `Tasks`) and before `WebSockets`. It provides the route transport those capabilities use to reach the root, restores the object's own child identity before anything else starts, and claims `/sub/` requests and upgrades ahead of the WebSockets fallback. Every host in the tree — parent and children — needs the `_cf_lifecycle` line; the capability throws at startup with that snippet when it is missing.

The capability's surface on a plain host:

- **Children**: `get(Cls, name)`, `abort(Cls, name, reason?)`, `delete(Cls, name)`, `has(Cls | className, name)`, `list(Cls?)`.
- **Identity**: `isChild`, `name` (the name the parent gave a child, or the routed name), `parentPath`, `selfPath`.
- **From inside a child**: `parent(Cls)` (a stub for the immediate parent, routed through the root), `deleteSelf()`, `broadcast(message, without?)` to the child's own connections, `keepAlive()` to hold the root's heartbeat (children have no alarm of their own), and `holdLease(id)` / `releaseLease(id)` for durable work the root should periodically ask the child to recover through the `checkLeases` option.
- **Options** are policy only: `onBeforeChild`, `checkLeases`, `keepAliveIntervalMs`.

HTTP requests and WebSocket upgrades to `/agents/{parent-class}/{name}/sub/{child-class}/{name}/...` are forwarded to the child after `onBeforeChild` allows them, with the `/sub/{class}/{name}` segment stripped. A child's sockets stay on the parent (a child owns no platform sockets) and are bridged into the child's own `WebSockets` capability, whose handlers, `getConnections()`, and `connection.setState()` see them like any other connection. Sockets accepted by a previous release through the parent's `WebSockets` capability keep reaching their child; an `Agent` skips them in its own `getConnections()`, and a plain host that wants the same filter uses `ownsConnection(connection)`.

See [`examples/next/dynamic-agents-plain`](https://github.com/cloudflare/agents/tree/main/examples/next/dynamic-agents-plain) for the complete workspace-and-notebooks example with tests.

## Server API

The capability lives at `this.dynamicAgents`. The legacy method names delegate to it and remain supported:
Inside an `Agent`, the capability lives at `this.dynamicAgents` — the same `DynamicAgents` instance, installed and configured by `Agent` (its `onBeforeChild` calls `this.onBeforeSubAgent`). The legacy method names delegate to it and remain supported:

| Legacy (deprecated) | Capability |
| -------------------------------- | -------------------------------------- |
Expand All @@ -165,7 +226,7 @@ await runner.ping();

The child class must:

- Extend `Agent`
- Be a Lifecycle Object with the `DynamicAgents` capability installed and the `_cf_lifecycle` aperture — every `Agent` subclass qualifies
- Be exported from the worker entry point (so `ctx.exports[Cls.name]` can find it)
- Does NOT need to be registered under `new_sqlite_classes` unless the same class is also bound as a top-level Durable Object elsewhere. Facet storage is created through the top-level parent.
- _Not_ share a name with the reserved token `"Sub"` (any class whose kebab-cased name equals `"sub"` is rejected; it would collide with the `/sub/` URL separator)
Expand Down Expand Up @@ -475,6 +536,7 @@ Each chat gets its own alarms, placement, and storage budget; deletion is one `c

## Examples

- [`examples/next/dynamic-agents-plain`](https://github.com/cloudflare/agents/tree/main/examples/next/dynamic-agents-plain) — the capability on plain Durable Objects: a workspace spawns notebooks with isolated storage, forwards HTTP to them, and bridges their WebSockets.
- [`examples/next/dynamic-agents`](https://github.com/cloudflare/agents/tree/main/examples/next/dynamic-agents) — the headline use case: a supervisor stores user-submitted Durable Object code, loads it via Worker Loader, and runs it as facets with isolated storage, supervised abort, and code upgrades over stable state.
- [`examples/agents-as-tools`](https://github.com/cloudflare/agents/tree/main/examples/agents-as-tools) — per-run child agents as tools with inline streaming.
- [`examples/multi-ai-chat`](https://github.com/cloudflare/agents/tree/main/examples/multi-ai-chat) — a multi-session chat app built on facet children under one `Inbox`. It works and demonstrates the routing surface, but for many long-lived chats per user prefer the top-level-DO-per-chat pattern in [`examples/next/chats`](https://github.com/cloudflare/agents/tree/main/examples/next/chats) — see [When to use dynamic agents](#when-to-use-dynamic-agents).
Expand Down
27 changes: 14 additions & 13 deletions examples/next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,20 @@ SDK pull requests. Keeping them under `examples/next` avoids presenting the new
composition patterns as part of the current stable examples before the stack
lands.

| Example | Status | Demonstrates |
| -------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| [`lifecycle`](./lifecycle) | Available | A plain `DurableObject` composed with `Lifecycle` and a reusable capability |
| [`schedules`](./schedules) | Available | `Scheduler` installed as a reusable lifecycle capability |
| [`tasks`](./tasks) | This PR | Durable replayable `Tasks` installed as a reusable lifecycle capability |
| [`streams`](./streams) | This PR | Durable `Streams` composed with `Tasks`, served over SSE |
| [`sessions`](./sessions) | This PR | Durable message trees, streamed reads, and Sessions-owned attachments |
| [`mcp-client`](./mcp-client) | Available | `MCPClientManager` installed as a reusable lifecycle capability |
| [`chats`](./chats) | This PR | One DO per chat plus a per-user push-based index, the recommended many-chats pattern |
| [`dynamic-agents`](./dynamic-agents) | This PR | A supervisor runs user-submitted code as facets: isolated storage, supervised abort, code upgrades over stable state |
| [`harnesses/codex`](./harnesses/codex) | This PR | A static Codex Rust/Wasm loop composed as a Lifecycle capability, using LanguageModelV4 and Shell Workspace |
| [`harnesses/pi`](./harnesses/pi) | This PR | Experimental: pi `AgentHarness` on a pinned pi dev build, composed as an example-local Lifecycle capability |
| [`harnesses/self-modifying`](./harnesses/self-modifying) | This PR | A Lifecycle capability runs editable harness revisions in fresh Dynamic Workers with trusted System tools and auto-discovered Custom tools |
| Example | Status | Demonstrates |
| -------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`lifecycle`](./lifecycle) | Available | A plain `DurableObject` composed with `Lifecycle` and a reusable capability |
| [`schedules`](./schedules) | Available | `Scheduler` installed as a reusable lifecycle capability |
| [`tasks`](./tasks) | This PR | Durable replayable `Tasks` installed as a reusable lifecycle capability |
| [`streams`](./streams) | This PR | Durable `Streams` composed with `Tasks`, served over SSE |
| [`sessions`](./sessions) | This PR | Durable message trees, streamed reads, and Sessions-owned attachments |
| [`mcp-client`](./mcp-client) | Available | `MCPClientManager` installed as a reusable lifecycle capability |
| [`chats`](./chats) | This PR | One DO per chat plus a per-user push-based index, the recommended many-chats pattern |
| [`dynamic-agents`](./dynamic-agents) | This PR | A supervisor runs user-submitted code as facets: isolated storage, supervised abort, code upgrades over stable state |
| [`dynamic-agents-plain`](./dynamic-agents-plain) | This PR | `DynamicAgents` installed as a reusable lifecycle capability on a plain Durable Object: children with isolated storage, forwarded HTTP and bridged WebSockets |
| [`harnesses/codex`](./harnesses/codex) | This PR | A static Codex Rust/Wasm loop composed as a Lifecycle capability, using LanguageModelV4 and Shell Workspace |
| [`harnesses/pi`](./harnesses/pi) | This PR | Experimental: pi `AgentHarness` on a pinned pi dev build, composed as an example-local Lifecycle capability |
| [`harnesses/self-modifying`](./harnesses/self-modifying) | This PR | A Lifecycle capability runs editable harness revisions in fresh Dynamic Workers with trusted System tools and auto-discovered Custom tools |

Each example is an independent workspace package and should stay focused on one
capability. Once the APIs are stable, move the examples into the main examples
Expand Down
Loading
Loading