Skip to content

Chained turns: pattern + helper for multi-phase auto-continuation #1386

Description

@jonnyparris

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

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:

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<StreamableResult>;
}

Plus a hook to detect "should I continue?" cleanly:

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 I can't open a PR, so here is my commit implementing beforeStep #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-🤖

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requeston the roadmapFeature accepted and planned for implementation

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions