cloudflare / cloudflare/agents
Think: returnable text stream for withVoice onTurn (voice ⨉ Think)
- Dominant language
- TypeScript
- Stars
- 5.6k
- Forks
- 711
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 53
Description
Parent: #1352
## Summary
`withVoice(Think)` works for STT → Think turn → TTS if `onTurn` waits for `runTurn({ mode: "wait" })` and returns a final string. That path keeps Think tools/session/channel policy, but it cannot use voice’s recommended **streaming TTS** path, because Think does not expose a **returnable** token/text stream from the agent loop.
Voice’s `onTurn` can return `string | AsyncIterable | ReadableStream | AI SDK fullStream` and sentence-chunks TTS as tokens arrive. Think’s stream surface is callback-only (`runTurn({ mode: "stream", callback })` → `Promise`). There is no first-class way to `return` Think’s agentic stream from `onTurn`.
## Versions
- `@cloudflare/think` `0.13.0`
- `@cloudflare/voice` `0.3.5`
## What we tried
Composition we want (same Durable Object, text chat + voice):
```ts
const VoiceThink = withVoice(Think);
export class FrameAgent extends VoiceThink {
configureChannels() {
return {
voice: {
kind: "voice",
ingress: { transport: "voice" },
instructions: "Keep replies short and speakable. No markdown.",
maxTurns: 4
}
};
}
async onTurn(transcript: string, context: VoiceTurnContext) {
// Desired: return a live text stream from Think’s agent loop
// so withVoice can TTS sentence-by-sentence while tools still run.
}
}
```
What works today (tradeoff we are shipping):
```ts
async onTurn(transcript: string, context: VoiceTurnContext) {
const result = await this.runTurn({
mode: "wait",
channel: "voice",
input: transcript,
signal: context.signal
});
// extract assistant text…
return text; // TTS starts only after the full Think turn finishes
}
```
What voice docs recommend for latency (bypasses Think’s loop unless tools are re-wired by hand):
```ts
async onTurn(transcript, context) {
const result = streamText({ model, messages, tools, abortSignal: context.signal });
return result.textStream; // or fullStream
}
```
Think stream mode is not returnable:
```ts
await this.runTurn({
mode: "stream",
channel: "voice",
input: transcript,
callback: {
onStart() {},
onEvent(json) {}, // UIMessageChunk JSON — push-only
onDone() {},
onError() {}
}
}); // Promise
```
Apps can invent a callback→AsyncIterable bridge, but that is special-case glue (#1352 explicitly wants to avoid), and it is easy to get wrong around tool steps, abort/`context.signal`, and interruption.
## Ask
Please add a first-class way for Think turns to produce something `withVoice`’s `onTurn` can return, e.g. one of:
1. **Returnable stream from `runTurn`** — e.g. `mode: "stream"` (or a new mode) that resolves to / returns `AsyncIterable` (or AI SDK-compatible text/full stream) while still running the Think agent loop (tools, channel policy, session).
2. **Documented `withVoice(Think)` recipe** — if the intended pattern is “bridge callback chunks yourself” or “use `streamText` in `onTurn` and call Think tools another way,” say so in Think channels + voice docs so apps do not guess.
Related: Think channels note that `voice` ingress applies policy but **out-of-band delivery is not yet wired**; that matches the feeling that voice × Think is policy-complete but transport/stream-incomplete.
## Why it matters
Without a returnable stream, apps that need Think (tools, workspace, session, `channel: "voice"` policy) must choose:
| Choice | Keeps Think loop | Streaming TTS |
| --- | --- | --- |
| `runTurn({ mode: "wait" })` then return string | yes | no (higher spoken latency) |
| `streamText` in `onTurn` | no (unless reimplemented) | yes |
| Homegrown callback bridge | yes (fragile) | yes |
We are keeping the wait-mode tradeoff in production for now. A returnable Think stream (or an official recipe) would close the main composition gap called out in #1352.
## Acceptance criteria
- [ ] Documented, supported path for `withVoice(Think)` where `onTurn` can feed **streaming** TTS without abandoning Think’s agent loop
- [ ] Abort/`context.signal` and barge-in still cancel in-flight Think work cleanly
- [ ] Channel policy (`channel: "voice"`) still applies on that path
- [ ] Example or doc section under voice × Think (#1352) showing the recommended pattern
## References
- Voice `onTurn` return types / streaming TTS: https://developers.cloudflare.com/agents/communication-channels/voice/
- Think `runTurn` modes (`wait` → `TurnResult`, `stream` → `Promise` + callback)
- Think channels `kind: "voice"` (policy; delivery not wired): Think channels docs
- Umbrella: #1352
- Related streaming TTS bug: #1364
Contributor guide
Assessment
This issue has not been assessed yet.