cloudflare / cloudflare/agents
Think: configureChannels() policy never applies to WebSocket chat turns (ws-chat admits the turn with no channel)
- 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
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