Skip to content

Commit 8a1ea48

Browse files
committed
feat(agents): rename context hooks and complete lifecycle coverage
Wire close/alarm through withContext, tighten context typing helpers, and align tests/docs/changeset with onContextStart/onContextEnd.
1 parent c3cb850 commit 8a1ea48

File tree

6 files changed

+430
-307
lines changed

6 files changed

+430
-307
lines changed

.changeset/green-jars-turn.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
---
22
"agents": minor
3-
"@cloudflare/ai-chat": patch
43
---
54

6-
Add hook-style runtime context lifecycle support in `agents` with `onCreateContext` / `onDestroyContext`, typed `this.context`, and context propagation via `getCurrentAgent().context` and `getCurrentContext()`.
7-
8-
Also update `@cloudflare/ai-chat` to keep `context` in the async agent scope during chat/tool execution so nested `getCurrentAgent()` reads stay consistent.
5+
Add hook-style runtime context lifecycle support in `agents` with `onContextStart` / `onContextEnd`, typed `this.context`, and context propagation via `getCurrentAgent().context` and `getCurrentContext()`.

design/context-api.md

Lines changed: 54 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
# `onCreateContext` / `onDestroyContext` API
1+
# `onContextStart` / `onContextEnd` API
22

33
> Extensible per-entry-point context for tracing, auth, and observability.
44
55
## Problem
66

7-
The SDK wraps 9 entry points with an internal `AsyncLocalStorage` but the store shape is fixed. Users who need tracing, OTel, auth context, etc. must:
7+
The SDK wraps lifecycle entry points with an internal `AsyncLocalStorage` but the store shape is fixed. Users who need tracing, OTel, auth context, etc. must:
88

99
1. Create a **second** `AsyncLocalStorage`
1010
2. Manually `.run()` it in every lifecycle hook
@@ -20,16 +20,16 @@ The SDK already does the hard work of wrapping every entry point. Users should p
2020
```typescript
2121
class Agent<Env, State, Props> {
2222
/** Override to provide per-entry-point context. */
23-
onCreateContext(input: AgentContextInput): unknown | Promise<unknown>;
23+
onContextStart(input: AgentContextInput): unknown | Promise<unknown>;
2424

2525
/** Override to clean up context resources (spans, timers). Called in finally. */
26-
onDestroyContext?(
27-
context: Awaited<ReturnType<this["onCreateContext"]>>,
26+
onContextEnd?(
27+
context: Awaited<ReturnType<this["onContextStart"]>>,
2828
input: AgentContextInput
2929
): void | Promise<void>;
3030

31-
/** Current context. Typed per-class via onCreateContext return type. */
32-
get context(): Awaited<ReturnType<this["onCreateContext"]>> | undefined;
31+
/** Current context. Typed per-class via onContextStart return type. */
32+
get context(): Awaited<ReturnType<this["onContextStart"]>> | undefined;
3333

3434
/** Run fn with context created from input. For custom entry points. */
3535
withContext<R>(
@@ -47,7 +47,7 @@ export function getCurrentAgent<T extends Agent>(): {
4747
connection: Connection | undefined;
4848
request: Request | undefined;
4949
email: AgentEmail | undefined;
50-
context: Awaited<ReturnType<T["onCreateContext"]>> | undefined;
50+
context: Awaited<ReturnType<T["onContextStart"]>> | undefined;
5151
};
5252
```
5353

@@ -135,12 +135,12 @@ export type AgentContextInput =
135135
import { Agent, getCurrentContext, type AgentContextInput } from "agents";
136136

137137
export class TracedAgent extends Agent<Env, MyState> {
138-
onCreateContext(input: AgentContextInput) {
138+
onContextStart(input: AgentContextInput) {
139139
const span = tracer.startSpan(`agent.${input.lifecycle}`);
140140
return { span, traceId: span.spanContext().traceId };
141141
}
142142

143-
onDestroyContext(ctx: { span: Span; traceId: string }) {
143+
onContextEnd(ctx: { span: Span; traceId: string }) {
144144
ctx.span.end();
145145
}
146146

@@ -165,9 +165,9 @@ function log(msg: string) {
165165

166166
## Typing Strategy
167167

168-
**Per-class inference via `ReturnType<this["onCreateContext"]>`** — no 4th generic parameter, no global pollution.
168+
**Per-class inference via `ReturnType<this["onContextStart"]>`** — no 4th generic parameter, no global pollution.
169169

170-
- `this.context` on a subclass is typed from that class's `onCreateContext` return type
170+
- `this.context` on a subclass is typed from that class's `onContextStart` return type
171171
- `getCurrentContext()` returns `unknown` (caller narrows)
172172
- `getCurrentAgent<MyAgent>().context` returns the typed context
173173
- Module augmentation available as opt-in escape hatch for `getCurrentContext()` in external code
@@ -185,14 +185,14 @@ declare module "agents" {
185185

186186
### Create vs Inherit
187187

188-
| Situation | Action |
189-
| -------------------------------------------------------------------------------- | ----------------------------------------------------------- |
190-
| Entry point (onRequest, onMessage, onConnect, onStart, onEmail, schedule, alarm) | **Create**: call `onCreateContext`, store result in ALS |
191-
| Custom method called from within a lifecycle hook | **Inherit**: ALS store already exists, pass through |
192-
| Custom method called with no parent ALS | **Create**: call `onCreateContext({ lifecycle: "method" })` |
193-
| `_flushQueue` callback with existing parent store | **Inherit**: queue flush is a continuation |
194-
| `_flushQueue` callback with no parent store | **Create**: `{ lifecycle: "queue", callback }` |
195-
| State change notification | **Inherit**: always inherits parent context |
188+
| Situation | Action |
189+
| -------------------------------------------------------------------------------- | ---------------------------------------------------------- |
190+
| Entry point (onRequest, onMessage, onConnect, onStart, onEmail, schedule, alarm) | **Create**: call `onContextStart`, store result in ALS |
191+
| Custom method called from within a lifecycle hook | **Inherit**: ALS store already exists, pass through |
192+
| Custom method called with no parent ALS | **Create**: call `onContextStart({ lifecycle: "method" })` |
193+
| `_flushQueue` callback with existing parent store | **Inherit**: queue flush is a continuation |
194+
| `_flushQueue` callback with no parent store | **Create**: `{ lifecycle: "queue", callback }` |
195+
| State change notification | **Inherit**: always inherits parent context |
196196

197197
### Entry Point Wrapping Pattern
198198

@@ -219,8 +219,8 @@ return agentContext.run(
219219
try {
220220
return await handler();
221221
} finally {
222-
if (this.onDestroyContext && userCtx != null) {
223-
await this.onDestroyContext(userCtx, input);
222+
if (this.onContextEnd && userCtx != null) {
223+
await this.onContextEnd(userCtx, input);
224224
}
225225
}
226226
}
@@ -230,38 +230,38 @@ return agentContext.run(
230230
### `withAgentContext` (auto-wrapped custom methods)
231231

232232
```
233-
if store exists with agent === this → INHERIT (no onCreateContext call)
234-
if no store → call onCreateContext({ lifecycle: "method", ... })
235-
- sync path: if onCreateContext returns Promise, warn and use undefined
233+
if store exists with agent === this → INHERIT (no onContextStart call)
234+
if no store → call onContextStart({ lifecycle: "method", ... })
235+
- sync path: if onContextStart returns Promise, warn and use undefined
236236
- this only triggers for methods called completely outside any lifecycle
237237
```
238238

239239
### Async Support
240240

241-
`onCreateContext` may return a value or a Promise. Internal helper:
241+
`onContextStart` may return a value or a Promise. Internal helper:
242242

243243
```typescript
244244
private async _resolveContext(input: AgentContextInput): Promise<unknown> {
245-
const result = this.onCreateContext(input);
245+
const result = this.onContextStart(input);
246246
return result instanceof Promise ? await result : result;
247247
}
248248
```
249249

250-
For the sync `withAgentContext` wrapper, only sync return values are supported. Async `onCreateContext` in this path logs a warning and falls back to `undefined`.
250+
For the sync `withAgentContext` wrapper, only sync return values are supported. Async `onContextStart` in this path logs a warning and falls back to `undefined`.
251251

252252
## Branch Scope
253253

254254
- No migration shims or aliases required on this branch
255-
- Hook names are updated directly to `onCreateContext` / `onDestroyContext`
255+
- Hook names are updated directly to `onContextStart` / `onContextEnd`
256256
- `AgentContextStore` carries `context: unknown`
257257

258258
## Files Changed
259259

260-
| File | Change |
261-
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
262-
| `packages/agents/src/internal_context.ts` | Add `AgentRuntimeContext` interface, `context` field to `AgentContextStore` |
263-
| `packages/agents/src/index.ts` | `onCreateContext`, `onDestroyContext`, `context` getter, `withContext`, `getCurrentContext`, `_resolveContext`, update 9 `agentContext.run()` sites, update `withAgentContext` |
264-
| `packages/agents/src/types.ts` | `AgentContextInput` type (or inline in index.ts) |
260+
| File | Change |
261+
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
262+
| `packages/agents/src/internal_context.ts` | Add `AgentRuntimeContext` interface, `context` field to `AgentContextStore` |
263+
| `packages/agents/src/index.ts` | `onContextStart`, `onContextEnd`, `context` getter, `withContext`, `getCurrentContext`, `_resolveContext`, update 9 `agentContext.run()` sites, update `withAgentContext` |
264+
| `packages/agents/src/types.ts` | `AgentContextInput` type (or inline in index.ts) |
265265

266266
## Call Sites to Update
267267

@@ -270,9 +270,11 @@ For the sync `withAgentContext` wrapper, only sync return values are supported.
270270
| ~898 | `onRequest` wrapper | `"request"` |
271271
| ~919 | `onMessage` wrapper | `"message"` |
272272
| ~1064 | `onConnect` wrapper | `"connect"` |
273+
| ~1100 | `onClose` wrapper | `"close"` |
273274
| ~1127 | `onStart` wrapper | `"start"` |
274275
| ~1562 | `_onEmail` | `"email"` |
275276
| ~2365 | schedule execution | `"schedule"` (+ `callback: row.callback`) |
277+
| ~2320 | `alarm` | `"alarm"` |
276278
| ~1857 | `_flushQueue` | **inherit** if parent store, else `"queue"` (+ `callback`) |
277279
| ~1249 | state change notification | **inherit** (always) |
278280
| ~527 | `withAgentContext` | `"method"` (only when no parent store) |
@@ -281,38 +283,38 @@ For the sync `withAgentContext` wrapper, only sync return values are supported.
281283

282284
### Test Agents (new file: `packages/agents/src/tests/agents/context.ts`)
283285

284-
| Agent | Purpose |
285-
| -------------------------- | ------------------------------------------------------------------------------ |
286-
| `TestContextAgent` | Full onCreateContext + onDestroyContext; logs every call; exposes via RPC/HTTP |
287-
| `TestNoContextAgent` | No onCreateContext override — backwards compat |
288-
| `TestAsyncContextAgent` | Async onCreateContext (simulates KV/JWT lookup) |
289-
| `TestThrowingContextAgent` | onCreateContext that throws on demand — fail-fast |
290-
| `TestContextScheduleAgent` | Schedule callback context verification |
286+
| Agent | Purpose |
287+
| -------------------------- | ------------------------------------------------------------------------- |
288+
| `TestContextAgent` | Full onContextStart + onContextEnd; logs every call; exposes via RPC/HTTP |
289+
| `TestNoContextAgent` | No onContextStart override — backwards compat |
290+
| `TestAsyncContextAgent` | Async onContextStart (simulates KV/JWT lookup) |
291+
| `TestThrowingContextAgent` | onContextStart that throws on demand — fail-fast |
292+
| `TestContextScheduleAgent` | Schedule callback context verification |
291293

292294
### Test Groups (new file: `packages/agents/src/tests/context.test.ts`)
293295

294-
**Group 1: onCreateContext invocation** — verify called with correct lifecycle at each entry point (request, connect, message, start).
296+
**Group 1: onContextStart invocation** — verify called with correct lifecycle at each entry point (request, connect, message, start).
295297

296-
**Group 2: Context inheritance** — verify custom methods inherit parent context; verify onCreateContext NOT re-called for inherited methods.
298+
**Group 2: Context inheritance** — verify custom methods inherit parent context; verify onContextStart NOT re-called for inherited methods.
297299

298300
**Group 3: getCurrentContext()** — verify accessible from external utility functions.
299301

300-
**Group 4: onDestroyContext** — verify called after onRequest, onMessage; verify matching traceId; verify called even on handler error.
302+
**Group 4: onContextEnd** — verify called after onRequest, onMessage; verify matching traceId; verify called even on handler error.
301303

302-
**Group 5: Backwards compatibility** — verify agents without onCreateContext work unchanged; this.context is undefined.
304+
**Group 5: Backwards compatibility** — verify agents without onContextStart work unchanged; this.context is undefined.
303305

304-
**Group 6: Async onCreateContext** — verify async onCreateContext resolves before handler runs.
306+
**Group 6: Async onContextStart** — verify async onContextStart resolves before handler runs.
305307

306-
**Group 7: Error handling** — verify onCreateContext throw prevents handler execution (fail fast, 500 response).
308+
**Group 7: Error handling** — verify onContextStart throw prevents handler execution (fail fast, 500 response).
307309

308310
### Type Tests (new file: `packages/agents/src/tests/context-types.test.ts`)
309311

310312
Compile-time only via `expectTypeOf`:
311313

312-
- `this.context` infers from `onCreateContext` return type
314+
- `this.context` infers from `onContextStart` return type
313315
- `this.context` is `unknown | undefined` when no override
314316
- `getCurrentContext()` returns `unknown`
315-
- `getCurrentAgent<T>().context` matches T's onCreateContext
317+
- `getCurrentAgent<T>().context` matches T's onContextStart
316318

317319
## Ecosystem Precedent
318320

@@ -325,7 +327,7 @@ Compile-time only via `expectTypeOf`:
325327
| **OTel JS** | `context.with(ctx, fn)` + `context.active()` | Symbol-keyed bag | Manual `span.end()` |
326328
| **Sentry CF** | `AsyncLocalStorage.run()` + `withScope()` | Internal typed scopes | `finish()` in finally |
327329

328-
This design follows tRPC's `createContext` pattern for the hook, OTel's `context.with` for `withContext`, and Fastify's `onRequestAbort` precedent for `onDestroyContext`.
330+
This design follows tRPC's `createContext` pattern for the hook, OTel's `context.with` for `withContext`, and Fastify's `onRequestAbort` precedent for `onContextEnd`.
329331

330332
## Resolved Design Questions
331333

@@ -339,10 +341,10 @@ Yes. Needed for webhook handlers, custom WS upgrades, testing.
339341
Yes. Both `getCurrentAgent().context` and `getCurrentContext()`.
340342

341343
**Q: Does OTel need a cleanup hook?**
342-
Yes. `onDestroyContext` called in `finally` at every entry point. Separate from `onCreateContext` (no `Disposable` coupling).
344+
Yes. `onContextEnd` runs in `finally` whenever `onContextStart` produced a non-nullish context value. Separate from `onContextStart` (no `Disposable` coupling).
343345

344346
**Q: Module augmentation vs generic?**
345347
Return-type inference primary. Module augmentation opt-in for `getCurrentContext()` typing.
346348

347-
**Q: Sync or async `onCreateContext`?**
349+
**Q: Sync or async `onContextStart`?**
348350
Allow async. Sync fast path in `withAgentContext` (auto-wrapped methods).

0 commit comments

Comments
 (0)