cloudflare / cloudflare/agents
Chained turns: pattern + helper for multi-phase auto-continuation
- Dominant language
- TypeScript
- Stars
- 5.6k
- Forks
- 711
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 53
Description
# Chained turns: pattern + helper for multi-phase auto-continuation
## Context
A common pattern for long-running coding/research agents on small-context-window models is **chained turns**: when one streamText call exhausts its `maxSteps` (or hits a budget threshold), the agent doesn't return to the user — it summarises its progress, rebuilds the messages array around the summary + last few messages, and runs another full streamText turn. This repeats up to N phases until either the model says "done" or a phase cap is hit.
dodo (autonomous coding agent on Workers/DOs) has been doing this since issue #34 hit production. The current shape lives inside dodo's `onChatMessage` override at https://github.com/jonnyparris/dodo/blob/main/src/coding-agent.ts#L1340-L1679 — five phases, each with its own messages reconstruction (digest substitution for the original prompt, tool-call digest injection so the model doesn't repeat work, key-findings extraction from dropped assistant messages, fresh tool-call accumulator, etc.).
This is **above** the streamText layer — between full turns, not within one turn. `beforeStep` (issue #1363) handles between-step mutation; this issue is about between-*turn* mutation.
## What dodo does today
```ts
override async onChatMessage(options) {
// Phase 0: original streamText with the user's messages
let result = await streamText({ messages, ... });
// If exhausted by step-limit / budget rather than model finishing:
for (let phase = 1; phase <= MAX_PHASES; phase++) {
// Compact: keep only recent assistant/tool turns, replace older with
// a synthesized digest. Substitute the original user prompt with a
// 500-char digest to stop paying for the whole prompt N times.
messages = compactForContinuation(messages, originalPromptDigest);
// Build a continuation user message that constrains the next phase:
// "[auto-continue] Files discovered: ... Tool calls already made: ...
// DO NOT re-explore, focus on the goal."
messages.push({ role: "user", content: continuationPrompt });
result = await streamText({ messages, ... });
if (result.finishReason === "stop") break;
}
}
```
Migrating this to Think 0.4.0's hook surface is the one place the design genuinely doesn't fit: `onChatResponse` fires *after* persistence, and re-entering via `saveMessages(...)` would persist the synthetic continuation user message, which is not what we want — it would show up in the user's message history.
## What I'd propose
A first-class helper for "run another turn against the current session, with these in-memory messages, without persisting the synthetic user message that triggered it." Sketch:
```ts
class Think {
/**
* Run another streamText turn against the current session using the
* provided in-memory messages, without persisting any synthetic
* messages used to trigger the continuation. The assistant's output
* IS persisted (and broadcast over the websocket / SSE) as normal.
*
* Useful for multi-phase auto-continuation patterns where the agent
* needs to "keep going" after exhausting maxSteps without showing the
* user a synthetic "[auto-continue]" user message in their history.
*/
protected async continueTurn(opts: {
/** Messages to send to the model on this continuation turn. */
messages: ModelMessage[];
/** Optional: maxSteps for this continuation (defaults to this.maxSteps). */
maxSteps?: number;
/** Optional: per-turn config overrides (same shape as TurnConfig). */
config?: TurnConfig;
/** Optional: caller's abort signal for the whole chain. */
signal?: AbortSignal;
}): Promise;
}
```
Plus a hook to detect "should I continue?" cleanly:
```ts
class Think {
/**
* Called after each turn completes. Return a non-null value to trigger
* another continueTurn() pass with the returned messages. Repeats up
* to maxContinuationPhases (default 1, i.e. no continuation).
*/
shouldContinueTurn?(ctx: {
result: StreamableResult;
phase: number;
history: ModelMessage[];
}): { messages: ModelMessage[]; config?: TurnConfig } | null | Promise<...>;
}
```
`maxContinuationPhases` would be a property defaulting to 1 (no continuation, current behaviour). Set to 5 to enable up to 5 chained turns.
## Why this is worth doing as first-class API
- It's a recurring pattern. Anyone running an agent against small-context models on long tasks ends up needing it.
- The mechanics are subtle (don't persist the synthetic user message, do persist the assistant output, replay broadcast across phases without confusing the WebSocket protocol, abort signal propagates to all phases, maxSteps applies per-phase). Getting this right once in the framework is much better than every consumer reinventing it.
- Without this, the only escape hatch is what dodo does today: extend `Agent` directly and reimplement what Think provides. That works but it's a lot of duplicated machinery.
## Workaround if not landing soon
dodo's current plan if `beforeStep` lands but `continueTurn` doesn't is:
1. Use `beforeStep` for between-step safety (items 1–6 from #1363).
2. For multi-phase continuation, call `streamText` directly from `onChatResponse` and write the result to the session through `appendMessage`/`saveMessages` with a flag that suppresses the synthetic user message persistence. That requires either (a) a `Session.appendMessage({ persist: false })` option for the synthetic user message, or (b) just owning the persistence ourselves and not calling `saveMessages` for synthetic content.
Either of those would be a smaller API change than `continueTurn` but somewhat hackier. Happy to take guidance on which shape the maintainers prefer.
## Filed as a follow-up to #1363
Per @threepointone's "looks good, PR today" on #1363 — once `beforeStep` lands, this is the remaining gap for full agentic-loop control without subclassing `Agent` directly. Not urgent (`beforeStep` is the bigger blocker), but useful to scope before building around it.
beep-boop-🤖
Contributor guide
Assessment
This issue has not been assessed yet.