Skip to content

Think: configureChannels() policy never applies to WebSocket chat turns (ws-chat admits the turn with no channel) #2255

Description

@lswith

Summary

configureChannels() policy is never applied to turns that arrive over the WebSocket chat protocol (cf_agent_use_chat_request). Declaring a web channel with instructions, tools or maxTurns has no effect on a browser chat client — the turn runs with no channel context, silently and with no warning.

The implicit web channel is registered at startup and looks ready to serve exactly this case, so this reads as an unfinished seam rather than a deliberate exclusion.

What I expected

Per the docs, declaring a web channel and giving it instructions should prepend those instructions to the system prompt for turns on that surface. The documented example is precisely this:

web:   { kind: "web",   ingress: { transport: "websocket" },
         instructions: "You are chatting in a web app. Use markdown freely." },
voice: { kind: "voice", ingress: { transport: "voice" },
         instructions: "Keep replies short and speakable. No markdown.", maxTurns: 3 }

What happens

Nothing. this.activeChannel is undefined for the turn, and no channel policy — instructions, tools, or maxTurns — is applied.

Why (trace, line numbers from the bundled dist/think.js in 0.17.0)

  1. resolveChannels() seeds the registry with an implicit web entry (:156-178), so this._channels always contains "web" with ingress: { transport: "websocket" }.

  2. _handleChatRequest admits the turn without a channel key (:6300-6307):

await this._admitTurn({
  admission: "queue",
  trigger: "ws-chat",
  requestId,
  generation: epoch,
  continuation: false,
  onQueued: releaseIfPending,
  execute: async () => { ... }
})
  1. _admitTurn forwards spec.channel (undefined) to _withChannelContext (:3596), and _resolveChannelContext returns immediately (:1775):
_resolveChannelContext(channel) {
  if (!channel) return;
  ...
}
  1. _runInferenceLoop therefore sees no context, and every consumer is skipped (:2670-2675):
const channelContext = this._activeChannelContext;                                   // undefined
const channelDefinition = channelContext ? this._channels?.get(channelContext.channelId) : void 0;  // undefined
if (channelDefinition?.tools) tools = channelDefinition.tools(tools);                 // skipped
const channelInstructions = channelDefinition?.instructions && channelContext ? ... : void 0;       // undefined
const baseSystem = channelInstructions ? `${channelInstructions}\n\n${rawBaseSystem}` : rawBaseSystem;

maxTurns is skipped the same way at :2724.

  1. The ws path also calls _runInferenceLoop directly (:6323-6334) rather than going through submitMessages(), so it never reaches _stampChannel (callers are only :3654, :5450, :5899). Nothing is persisted on the user message, so _channelFromLatestUserMessage() cannot recover a channel for a continuation or retry either.

Every other ingress does pass one: messenger turns via chatWithMessengerContext (channel: context.messengerId, :1737), and chat() / runTurn() / submitMessages() via options.channel (:3651, :3775-3828, :5450). The WebSocket path is the only one that does not.

The client cannot supply it either

  • channel does not appear anywhere in @cloudflare/think's dist/react.d.ts or in agents@0.22.0's dist/chat/react.d.ts — there is no option on useAgent / useAgentChat to name a channel.
  • Unrecognised keys on the request frame land in customBody / _lastBody and are never consulted for channel selection.
  • A client-supplied metadata.channel is stripped at intake: RESERVED_MESSAGE_METADATA_KEYS = ["channel", "turnMetadata"] (:807), removed by _stripReservedMessageMetadata (:7145-7147), whose docstring says a client must never be able to forge them.

So there is no supported workaround short of reimplementing the prepend in beforeTurn.

It fails silently

The console.warn at :1778 only fires when a channel was requested and is not registered. A turn that never requested one produces no warning, so a declared-but-inert channel looks like it is working.

The type docs claim the opposite

dist/index-B7zEkBBM.d.ts:336-341, on RunTurnBase.channel:

/**
 * Channel id this turn belongs to (resolved against `configureChannels()` /
 * `getMessengers()`). Sets the turn-scoped channel context and is persisted on
 * the user message so a recovered/continued turn re-resolves it. Defaults to
 * the implicit `web` channel.
 */
channel?: string;

"Defaults to the implicit web channel" is not what any turn path does — there is no ?? "web" on a turn path anywhere in the bundle; the only one is deliverNotice's (:1868). docs/channels.md:103 agrees with the code rather than the type: "A turn with no channel runs without a channel context and applies no channel policy."

Whichever of those two is intended, they currently disagree.

Repro

  1. A Think subclass reached over the WebSocket chat protocol (useAgentChat).
  2. Implement configureChannels() returning { web: { kind: "web", ingress: { transport: "websocket" }, instructions: "Always begin every reply with the word BANANA." } }.
  3. Send a message from the browser client.
  4. The reply does not begin with BANANA, and this.activeChannel is undefined inside the turn.

Suggested fix

Stamp the implicit web channel on ws-chat turns — pass channel: "web" in the _admitTurn spec at :6300, and persist it so continuations re-resolve — which would make the behaviour match the RunTurnBase.channel docstring.

If it is intended to stay opt-in instead, then either a way for the ws client to name its channel, or a note in docs/channels.md that a web entry applies to server-driven turns only, would close the gap. At the moment the documented web example cannot work for the surface it names.

Version

@cloudflare/think@0.17.0, agents@0.22.0.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions