Skip to content

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

Closed

Description

@poggersdave

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:
    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:
    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.

Activity

  1. apercivall commented on Jul 16, 2026

    @apercivall

    +1 — hitting this on think@0.13.0 / agents@0.17.4.

    Use case: one Agent DO per user as the isolation boundary, with a multi-thread chat UI on top. We want threads to be independent conversations without sharding a DO per thread. Today they're cosmetic to the model — beforeTurn has no thread identity and history is assembled from the single this.session before it runs, so a turn sees the globally-latest tail rather than the active thread's.

    SessionManager.getSession(id) already exists — the primitive's there; Think just binds one Session. What would unblock us: a race-free, per-turn session id resolved inside the TurnQueue so history is read from that session. Read-only ctx exposure alone wouldn't be enough without the selection seam.

    Happy to test a preview build against a real per-user multi-thread workload.

  2. threepointone commented on Aug 5, 2026

    @threepointone
    Contributor

    ai v7's context might help here?

  3. cjol commented on Aug 11, 2026

    @cjol
    Member

    Use case: one Agent DO per user as the isolation boundary, with a multi-thread chat UI on top. We want threads to be independent conversations without sharding a DO per thread.

    @apercivall We generally use a separate DO for each conversation/thread. If you want to share a DO across multiple conversations, sub-agents are the current solution (see the multi-ai chat example). Is there a reason you are otherwise trying to keep multiple user conversations inside a single DO? There is a bit of a tricky situation with messengers that use a single webhook across all users and threads, because they currently resolve to a single DO. It's workaround-able by owning more of the routing at the Worker-level with getAgentByName, but I'd like to improve this story in future.

    @poggersdave Regarding the original problem of accessing _activeMessengerContext -- in line with the above, it's most often the case that thread context shouldn't change from one message to the next, but there is nonetheless a race in the case that you are smushing messages from multiple threads into a single Think conversation. I think the most common failure mode for this is likely multi-user conversations with two users posting into a single thread concurrently.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions