cloudflare / cloudflare/agents

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

Open
#2,255 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
5.6k
Forks
711
Avg merge
1d 20h
Merged PRs (30d)
53

Description

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

```ts
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`):

```js
await this._admitTurn({
admission: "queue",
trigger: "ws-chat",
requestId,
generation: epoch,
continuation: false,
onQueued: releaseIfPending,
execute: async () => { ... }
})
```

3. `_admitTurn` forwards `spec.channel` (undefined) to `_withChannelContext` (`:3596`), and `_resolveChannelContext` returns immediately (`:1775`):

```js
_resolveChannelContext(channel) {
if (!channel) return;
...
}
```

4. `_runInferenceLoop` therefore sees no context, and every consumer is skipped (`:2670-2675`):

```js
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`.

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

```ts
/**
* 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`.

Contributor guide

Open the contributing guide

Research direction

Start at the WebSocket entry point `_handleChatRequest` and trace its `_admitTurn` call into `_resolveChannelContext` and `_runInferenceLoop`; the relevant bundled references are in `dist/think.js`. Compare the `RunTurnBase.channel` documentation in `dist/index-B7zEkBBM.d.ts` with `docs/channels.md`, then verify that ws-chat turns apply the declared web policy and preserve channel context for continuation or retry.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
64/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.