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

Vendor the required PartyServer runtime into `agents/lifecycle` and add a reusable Durable Object lifecycle for startup, request interception, alarms, and WebSockets. `Agent` now directly extends Cloudflare's `DurableObject` and composes the same lifecycle used by standalone objects; standalone hosts use the explicit `Lifecycle.install(this)` factory (or the expanded `new ...` plus `installHandlers()` form).

Lifecycle WebSockets always use Cloudflare's Hibernation API; the `static options.hibernate` switch and in-memory connection mode are removed. Named Durable Objects use native `ctx.id.name`, while a read-only `__ps_name` fallback migrates objects created by older releases without writing new compatibility state.
6 changes: 6 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ This project incorporates code and inspiration from the following open source pr
- Copyright: Telnyx
- Note: Portions of @cloudflare/voice-telnyx are adapted from @telnyx/voice-cloudflare.

6. PartyServer
- License: ISC License
- Repository: https://github.com/cloudflare/partykit/tree/main/packages/partyserver
- Copyright: 2024 Sunil Pai <spai@cloudflare.com>
- Note: The Agents SDK vendors and adapts PartyServer's Durable Object server implementation.

The original licenses and copyright notices from these projects are preserved and should be included with any distribution of this software.

For a complete list of third-party licenses, see [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md).
8 changes: 8 additions & 0 deletions THIRD_PARTY_LICENSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ This project incorporates code from the following open-source projects.

## Dependencies

### PartyServer

- **License**: ISC License
- **Repository**: https://github.com/cloudflare/partykit/tree/main/packages/partyserver
- **Copyright**: Copyright 2024 Sunil Pai <spai@cloudflare.com>
- **Full License**: [licenses/isc-partyserver.txt](licenses/isc-partyserver.txt)
- **Vendored from**: [cloudflare/partykit@f0a2e97d](https://github.com/cloudflare/partykit/commit/f0a2e97d233f24545b2648aec2ed6a191e11074e)

### Anthropic MCP TS SDK

- **License**: Apache License 2.0
Expand Down
4 changes: 2 additions & 2 deletions agent-think/test/prompt-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { env } from "cloudflare:test";
import { getServerByName } from "partyserver";
import { getAgentByName } from "agents";
import { describe, expect, it } from "vitest";
import type { AgentThinkPromptTestAgent } from "./worker";

async function freshPromptAgent(name: string) {
return getServerByName(
return getAgentByName(
env.AgentThinkPromptTestAgent as unknown as DurableObjectNamespace<AgentThinkPromptTestAgent>,
name
);
Expand Down
1 change: 1 addition & 0 deletions design/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ Keep it concise. A few paragraphs is fine. These are records, not essays.
| `rfc-coding-agent.md` | RFC | `CodingAgent` — new `@cloudflare/coding-agent` package (extends AIChatAgent), CLI coding agents in Sandbox, pluggable engine (Cli/Harness), two-lifecycle durability (proposed) |
| `test-coverage-matrix.md` | design doc | Feature × test-layer coverage rollup, CI→layer mapping, skipped-test debt, nightly hygiene |
| `mcp.md` | design doc | Stateless, Legacy compatibility, Legacy sessionful, client, package boundary, and conformance architecture |
| `rfc-durable-object-lifecycle.md` | RFC | Constructor-composed Durable Object lifecycle with reusable components and always-hibernating WebSockets |

## Relationship to `/docs`

Expand Down
10 changes: 5 additions & 5 deletions design/readonly-connections.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ The `state` getter also calls `_setStateInternal` for initialization (persisting
The readonly flag storage went through three designs:

1. **SQL table** (original PR) — `CREATE TABLE cf_agents_readonly_connections`. Worked but added schema, queries, and cleanup logic for a single boolean.
2. **`connection.setState({ _readonly: true })`** (first refactor) — leveraged partyserver's built-in per-connection state, which survives hibernation. Much simpler. But had a fatal flaw: any call to `connection.setState({ ... })` without the callback form would overwrite `_readonly`.
2. **`connection.setState({ _readonly: true })`** (first refactor) — leveraged lifecycle-managed per-connection state, which survives hibernation. Much simpler. But had a fatal flaw: any call to `connection.setState({ ... })` without the callback form would overwrite `_readonly`.
3. **Namespaced connection attachment** (current) — wraps `connection.state` and `connection.setState()` on each connection to hide the `_cf_readonly` key from user code.

### How the wrapping works
Expand All @@ -106,7 +106,7 @@ When the Agent first encounters a connection (in `onConnect` or `onMessage`), `_
4. **Overrides** `connection.state` (getter) to strip `_cf_readonly` from the returned value
5. **Overrides** `connection.setState` to preserve `_cf_readonly` when user code sets new state

The accessor vs. data property distinction matters because partyserver defines `state` as a getter (via `Object.defineProperties`), but we also need to handle non-partyserver connections or future implementations where `state` might be a plain data property. Without this, the fallback `() => connection.state` would call our overridden getter after the property is replaced, creating an infinite loop.
The accessor vs. data property distinction matters because the lifecycle defines `state` as a getter (via `Object.defineProperties`), but virtual facet connections may expose `state` as a plain data property. Without this, the fallback `() => connection.state` would call our overridden getter after the property is replaced, creating an infinite loop.

After wrapping:

Expand All @@ -115,11 +115,11 @@ After wrapping:
- `connection.setState((prev) => ({ ...prev, count: 1 }))` receives `prev` without `_cf_readonly`, but the flag is merged back in
- `setConnectionReadonly` / `isConnectionReadonly` use `_rawStateAccessors` to read/write the flag directly

### Why this required a partyserver change
### Lifecycle connection requirement

Partyserver defines `state` and `setState` on connection objects via `Object.defineProperties` — and prior to our patch, both properties had `configurable: false` (the default). This prevented us from redefining them with `Object.defineProperty`.

The fix was a two-line change in partyserver: add `configurable: true` to both the `state` and `setState` descriptors in `createLazyConnection`. The default behavior is unchanged — `configurable` only means the property _can_ be redefined, not that it behaves differently.
The lifecycle connection wrapper marks both the `state` and `setState` descriptors as `configurable`. The default behavior is unchanged — `configurable` only means the property _can_ be redefined, not that it behaves differently.

### Why `_cf_readonly` and not `_readonly`?

Expand All @@ -133,7 +133,7 @@ The single-key approach (`_cf_readonly` alongside user keys) is simpler, handles

### What about `getConnections()`?

Connections returned by `getConnections()` are the same JavaScript objects that were wrapped in `onConnect`/`onMessage` (partyserver's `createLazyConnection` checks `isWrapped(ws)` and returns the existing wrapper). So our `Object.defineProperty` overrides persist.
Connections returned by `getConnections()` are the same JavaScript objects that were wrapped in `onConnect`/`onMessage` (the lifecycle connection wrapper returns an already-wrapped socket unchanged). So our `Object.defineProperty` overrides persist.

After hibernation, the Durable Object creates new wrapper objects for rehydrated WebSockets. The first `onMessage` call re-wraps them via `_ensureConnectionWrapped`.

Expand Down
2 changes: 1 addition & 1 deletion design/retries.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The `cloudflare/actors` library includes well-tested retry primitives (`tryN`, `
3. **Per-call-site configurability** — `schedule()`, `scheduleEvery()`, and `queue()` accept an optional `{ retry?: RetryOptions }` parameter so developers can tune retry behavior per task.
4. **Backward compatible** — all new parameters are optional. Existing code works unchanged. Schema migrations use `ADD COLUMN IF NOT EXISTS` pattern.
5. **Sensible defaults** — 3 attempts, 100ms base delay, jittered exponential backoff. No configuration needed for the common case.
6. **Class-level defaults** — override defaults for an entire agent via `static options = { retry: { ... } }`, following the existing pattern for `hibernate`, `sendIdentityOnConnect`, etc.
6. **Class-level defaults** — override defaults for an entire agent via `static options = { retry: { ... } }`, following the existing static-options pattern used by `sendIdentityOnConnect` and recovery settings.

## Architecture

Expand Down
148 changes: 148 additions & 0 deletions design/rfc-durable-object-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
Status: accepted

# Durable Object lifecycle composition

## Problem

`Agent` historically inherited Durable Object routing and WebSocket behavior
from PartyServer while implementing every higher-level capability directly in
the base class. That made capabilities such as MCP difficult to reuse in a
plain Durable Object.

An earlier extraction introduced an Agent-only lifecycle and a broad hidden
host interface. It reduced code in `Agent`, but a capability still could not be
constructed independently and installed into another Durable Object.

## Decision

Vendor the required PartyServer runtime from
[`cloudflare/partykit@f0a2e97d`](https://github.com/cloudflare/partykit/commit/f0a2e97d233f24545b2648aec2ed6a191e11074e)
directly into `packages/agents/src/lifecycle` under its ISC license. Do not
publish a separate PartyServer package and do not retain a `Server` base class.

A class extends the platform `DurableObject` directly and constructs one
`Lifecycle`:

```ts
import { DurableObject } from "cloudflare:workers";
import { Lifecycle } from "agents/lifecycle";

export class MyObject extends DurableObject<Env> {
readonly lifecycle = Lifecycle.install(this);

onStart() {}

onRequest(request: Request) {
return new Response(request.url);
}

onAlarm() {}
}
```

The side-effect-named `install(this)` factory constructs the lifecycle and
explicitly installs platform `fetch`, `alarm`, `webSocketMessage`,
`webSocketClose`, and `webSocketError`. The expanded `new ...` plus
`installHandlers()` form remains available. The class implements semantic
callbacks rather than forwarding runtime handlers manually.

`Agent` follows the same composition:

```text
DurableObject
└── Agent
└── owns Lifecycle
```

There is one lifecycle implementation and no nominally distinct server class.

## Capabilities

Reusable capabilities implement only phases they need:

```ts
interface DurableObjectCapability<Props> {
onStart?(context: { props: Props | undefined }): void | Promise<void>;
onRequest?(context: {
request: Request;
}): Response | undefined | void | Promise<Response | undefined | void>;
onAlarm?(): void | Promise<void>;
}
```

A host can install and add a capability in one field initializer:

```ts
readonly lifecycle = Lifecycle.install(this).use(this.mcp);
```

Dependencies such as storage, bindings, clocks, authentication, observability,
and protocol publication are supplied explicitly when constructing the
capability. Capabilities do not receive the whole Agent through an implicit host
registry.

Phase policy is fixed:

- capability startup runs sequentially in registration order, followed by the
host's `onStart`;
- requests stop at the first capability `Response`, otherwise the host's
`onRequest` runs;
- capability alarms run sequentially in registration order, followed by the
host's `onAlarm`;
- a failure stops the phase and propagates;
- failed startup can be retried.

There is no disposal phase. Workers provide no eviction callback, so cleanup
that must happen explicitly belongs on the capability itself, such as
`MCPClientManager.close()`.

## WebSockets

Lifecycle WebSockets always use Cloudflare's Hibernation API. An idle socket
must not keep the Durable Object active and billed. There is no configuration
for an in-memory mode.

The lifecycle accepts sockets with `DurableObjectState.acceptWebSocket`, stores
connection metadata in WebSocket attachments, and dispatches wakes through the
host's `onConnect`, `onMessage`, `onClose`, and `onError` callbacks.

## Identity

For supported objects, native `ctx.id.name` is authoritative. Cloudflare makes
it available for `idFromName()` and `getByName()`, including alarm handlers.
Agents and Agent facets use named IDs.

The lifecycle never writes a duplicate name and does not support deprecated
naming headers or bootstrap RPCs. It performs one read of the historical
`__ps_name` key only when native identity is absent, allowing objects created by
older PartyServer releases to migrate.

If neither source exists, the error explains that:

- the object must be addressed with `idFromName()` or `getByName()`;
- local Wrangler/workerd and the compatibility date should be current;
- `newUniqueId()`, `idFromString()`, and names over 1,024 bytes do not expose a
name;
- alarms created before 2026-03-15 must be rescheduled from a named fetch or RPC
handler.

## Native RPC

Native Durable Object RPC bypasses `fetch`; no library can transparently
intercept arbitrary RPC methods. An RPC implementation that needs startup calls:

```ts
async myRpcMethod() {
await this.lifecycle.start();
// initialized work
}
```

Agent's internal RPC wrappers already enforce this boundary.

## Non-goals

- AI turns are not generic Durable Object lifecycle phases.
- Capabilities do not independently own the single physical alarm timestamp.
- The lifecycle does not offer alternate dispatch policies, middleware `next()`
semantics, an in-memory WebSocket mode, or speculative teardown hooks.
14 changes: 5 additions & 9 deletions design/rfc-sub-agent-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,23 +342,19 @@ const history = await chat.getHistory();

Side-by-side with `this.subAgent(...)`: inside a parent DO, `this.subAgent(Cls, name)` is the direct path. `getSubAgentByName(parent, Cls, name)` is for callers _outside_ the parent DO that don't want to write an explicit bridge method for every child method they care about.

### D9. Implementation location — agents first, partyserver later
### D9. Implementation location

partyserver is Cloudflare-specific, so facet mechanics could live there. But:

- `ctx.facets` is already wired through agents' `FacetCapableCtx`.
- The "fetch an upgrade through a Fetcher" pattern has no generic partyserver abstraction yet.
- It's faster to iterate on the semantics in one package.

Ship in `agents` first, extract URL parsing and forwarding primitives to partyserver once the shape has stabilized.
Facet routing remains in `agents`: it owns Agent paths, authorization hooks,
and `FacetCapableCtx`. Generic Durable Object dispatch lives in
`agents/lifecycle`; there is no separate PartyServer package to extract into.

## Edge cases and semantics

Consolidated list of corner cases and the answers we've committed to:

- **Hook throws.** Propagates. The DO runtime surfaces 500 to the client. Users who want custom error handling wrap in try/catch themselves — matches how `onChatMessage` errors behave today.
- **Hook ordering vs class-existence.** The hook runs _before_ the framework checks that the child class exists in `ctx.exports`. This lets users intercept with a custom response even for unknown classes. If the hook returns void and the class is missing, the framework returns a default 404 with a diagnostic body.
- **Request URL rewrites.** The hook receives the original request with its full URL intact — including the `/sub/{class}/{name}` segment — mirroring how partyserver's `onBeforeConnect` / `onBeforeRequest` pass through the un-stripped URL. The routing decision for which facet to wake is fixed at parse time; if the hook returns a modified `Request`, its headers, body, method, and query string flow to the child, but the **pathname** the child sees is always `match.remainingPath` (the tail after `/sub/{class}/{name}`). Customize via headers/body rather than URL-rewriting if the child's path needs to look different.
- **Request URL rewrites.** The hook receives the original request with its full URL intact — including the `/sub/{class}/{name}` segment — matching how lifecycle `onBeforeConnect` / `onBeforeRequest` hooks receive the unstripped URL. The routing decision for which facet to wake is fixed at parse time; if the hook returns a modified `Request`, its headers, body, method, and query string flow to the child, but the **pathname** the child sees is always `match.remainingPath` (the tail after `/sub/{class}/{name}`). Customize via headers/body rather than URL-rewriting if the child's path needs to look different.
- **Header/auth propagation.** Headers flow through to the child verbatim unless the hook rewrites. Cookies, `Authorization`, custom headers — all visible to the child as sent by the client.
- **Reconnect terminal vs transient.** Documented in D6. `useAgent` stops on 4xx and WS codes 1008 / 4xxx; retries everything else.
- **Basepath composition.** Router strips `basePath` first, then parses `/{prefix}/{class}/{name}[/sub/...]`. Nothing special for sub-agents.
Expand Down
10 changes: 4 additions & 6 deletions design/rfc-sub-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,10 @@ The `SubAgentClass<T>` type uses `env: never` as a variance trick. Since `never`

### Initialization

`subAgent()` does two things:

1. `ctx.facets.get(name, () => ({ class: exports[cls.name] }))` — creates or retrieves the facet
2. A set-name fetch (`/cdn-cgi/partyserver/set-name/`) — triggers `Server` initialization, which calls `onStart()` on first access

The set-name fetch is the same pattern used by `getAgentByName` / `getServerByName`. It's a no-op if the child is already initialized. `onStart()` runs lazily on first `subAgent()` call, not eagerly on parent construction.
`subAgent()` creates or retrieves the facet with an explicit named
`FacetStartupOptions.id`, then invokes `_cf_initAsFacet()` over native RPC. The
child records Agent-specific parent metadata and starts its composed lifecycle.
`onStart()` runs lazily on first access, not during parent construction.

### Validation

Expand Down
2 changes: 1 addition & 1 deletion design/sub-agent-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ 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
The SDK also passes an explicit `id` to `ctx.facets.get()` so lifecycle identity 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
Expand Down
Loading
Loading