feat(routing): add RoutedAgents capability for a hub Agent that routes to independent Agents (#2198)
## Summary
Codifies the accepted user-hub topology from `design/rfc-user-chat-durable-objects.md`: one Agent (a per-user hub) owns a durable set of independent top-level Agents (one Durable Object per chat, document, or session) and routes to them by public ID. This is the counterpart to dynamic agents (facets): facets are for code the parent supervises; `RoutedAgents` is for independent peers.
```ts
import { Agent } from "agents";
import { RoutedAgents } from "agents/routing";
class UserAgent extends Agent<Env> {
readonly chats = new RoutedAgents<ChatAgent, { title: string }>({
namespace: this.env.ChatAgent,
route: "chats"
});
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.lifecycle.use(this.chats);
}
}
```
```
/agents/user-agent/alice/chats/{id}/... -> the ChatAgent behind that entry, suffix preserved
```
## What it does
- `create()`, `list()`, `setMetadata()` touch only the hub's SQLite; no target wakes.
- `get(id)` returns an initialized typed stub, or `null` for unknown or deleted IDs.
- `delete(id)` marks the entry `deleting`, destroys the target's storage, then drops the row. A failed destroy leaves a hidden row and a repeated call retries.
- HTTP requests and WebSocket upgrades are forwarded to the target. The target answers the upgrade and owns the socket, so chat frames never wake the hub (the nested route the RFC's deployed spike selected).
- Physical Durable Object names are random UUIDs held only in the hub. Clients only see entry IDs.
- Every `/{route}/{id}` occurrence in the path is tried against the entries, so the route segment may also appear as the owner's own name or inside the forwarded suffix.
- `namespace` is any `DurableObjectNamespace`, including a `script_name` binding to a class in another Worker.
- Table is `WITHOUT ROWID` with no secondary index: one billed row per create.
## Lifecycle
`Lifecycle.use(capability, { fallback: true })` dispatches a capability after every non-fallback one regardless of installation order. `Agent` installs its WebSockets capability as the fallback, so a subclass that installs request or upgrade middleware from its own constructor with `this.lifecycle.use()` runs first, the same way Think installs Streams. No Agent-specific install helper and no knowledge of dispatch order needed.
## Also
- `docs/agents/routing.md` gains "Routing to independent Agents"; `docs/agents/lifecycle.md` documents fallback dispatch; the dynamic-agents decision rule and docs index link to the new section.
- `design/rfc-think-multi-session.md` is marked rejected; the user/chat RFC is marked accepted with the spike results recorded.
- Changeset: `agents` minor.
## Tests
- `src/tests/routed-agents.test.ts` (workers pool): create/list, metadata updates without waking targets, HTTP forwarding with suffix, 404 for unknown IDs, route-segment collision, target-owned WebSocket surviving target eviction, delete making the entry unreachable before destroying storage.
- `src/tests/lifecycle/startup.test.ts`: fallback capabilities dispatch after later-installed ones.
- `src/tests-d/routing-export.test-d.ts`: public types.
## Example
`examples/next/chats` is rebuilt on `RoutedAgents`. The hub creates, lists, searches, and deletes chats through the capability; each chat learns its owner through one `init()` call after creation and pushes its title and last message back with `setMetadata()`. The React client connects to a chat with `useAgent({ basePath: "agents/user-agent/{user}/chats/{id}" })`, and the example tests drive chats through that same route, so physical names never leave the hub.
https://claude.ai/code/session_012zG7XvA1rUNjfSDViv711r M
Matt committed
99e5e2ecfed8ffb94b5d8fab1a80e3a62d7d9ee9
Parent: bcbfe80
Committed by GitHub <noreply@github.com>
on 9/2/2026, 10:18:39 AM