You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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
overrideasynconChatMessage(options){// Phase 0: original streamText with the user's messagesletresult=awaitstreamText({ messages, ... });// If exhausted by step-limit / budget rather than model finishing:for(letphase=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=awaitstreamText({ 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:
classThink{/** * 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. */protectedasynccontinueTurn(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<StreamableResult>;}
Plus a hook to detect "should I continue?" cleanly:
classThink{/** * 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:
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.
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.
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
onChatMessageoverride 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
Migrating this to Think 0.4.0's hook surface is the one place the design genuinely doesn't fit:
onChatResponsefires after persistence, and re-entering viasaveMessages(...)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:
Plus a hook to detect "should I continue?" cleanly:
maxContinuationPhaseswould 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
Agentdirectly 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
beforeSteplands butcontinueTurndoesn't is:beforeStepfor between-step safety (items 1–6 from I can't open a PR, so here is my commit implementing beforeStep #1363).streamTextdirectly fromonChatResponseand write the result to the session throughappendMessage/saveMessageswith a flag that suppresses the synthetic user message persistence. That requires either (a) aSession.appendMessage({ persist: false })option for the synthetic user message, or (b) just owning the persistence ourselves and not callingsaveMessagesfor synthetic content.Either of those would be a smaller API change than
continueTurnbut 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
beforeSteplands, this is the remaining gap for full agentic-loop control without subclassingAgentdirectly. Not urgent (beforeStepis the bigger blocker), but useful to scope before building around it.beep-boop-🤖