cloudflare / cloudflare/agents

Think: expose immutable per-turn messenger/thread context on beforeTurn ctx (concurrent turns race on _activeMessengerContext)

Open
#1,894 3 comments 0 reactions 1 assignee Claimed by @cjol View on GitHub
think
Dominant language
TypeScript
Stars
5.6k
Forks
711
Avg merge
1d 20h
Merged PRs (30d)
53

Description

### Summary

`beforeTurn(ctx)` exposes no immutable, per-turn identity for *which* messenger thread/session the turn belongs to. The only way to recover it from inside `beforeTurn` is `getMessengerContext()`, which reads the mutable `_activeMessengerContext` field — and that field is set **outside** the `TurnQueue`, so it races under concurrent turns on one DO. This makes per-thread turn logic (e.g. scoping the model-visible transcript to the triggering thread) impossible to implement correctly from the public hook. Related to (but distinct from) the closed #1349 — that was the `this.session` / `useAgentChat` cache-key layer; this is the `beforeTurn`/`ctx` layer.

### Mechanics (from `@cloudflare/think@0.11.1` + `agents@0.17.1`)

1. `getMessengerContext()` returns the mutable field, else the last message's metadata:
```js
getMessengerContext() {
if (this._activeMessengerContext) return this._activeMessengerContext;
return this.messages.at(-1)?.metadata?.messenger;
}
```
2. `chatWithMessengerContext` sets that field **before** `await this.chat(...)` and restores it only in `finally` — i.e. outside the turn queue:
```js
this._activeMessengerContext = context;
try { await this.chat(...); } finally { this._activeMessengerContext = previous; }
```
3. `TurnQueue.enqueue` runs the turn body only after `await previousTurn`, and the user-message append happens *inside* that serialized body.
4. The `ctx` passed to `beforeTurn` is `{ system, messages, tools, model, continuation, body }` — the messages are already assembled model messages (no per-message messenger metadata), and there is **no** thread/session id.

### The race

Same DO handles two near-simultaneous events for threads A then B (DOs interleave at await points):

- Event A: `_activeMessengerContext = A`; `await chat(...)` (enqueues turn A).
- Event B interleaves during A's awaits: `_activeMessengerContext = B` (overwrite); enqueues turn B behind A.
- Turn A's body runs; its `beforeTurn` reads `getMessengerContext()` → **B**. Turn A now operates with thread B's context.

Anchoring on `this.messages` instead (newest tagged user message) is race-free for *fresh* turns, because appends are serialized by the queue — but **continuation turns** (`_scheduleAutoContinuation` → `_admitTurn({ continuation: true })`) re-enter the queue as separate turns, so a cross-thread message can interleave between a tool-call turn and its continuation and poison the anchor. There is no fully-correct fix available from `beforeTurn` today.

### Ask

Thread an immutable per-turn messenger/thread identity through the turn and surface it on the `beforeTurn` `ctx` (e.g. `ctx.messenger` / `ctx.threadId`), captured at admission time so it flows with the unit of work through `TurnQueue` rather than being read from a shared mutable field. (Alternatively/additionally, stamp the resolved thread on every appended message, not just the user trigger.) That would let per-thread turn logic be written correctly without reading `_activeMessengerContext` or inferring position in a shared history.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.