Agents Honestly
Part XXI · Pattern CatalogDurability Patterns

Continue-As-New for Memory

Reset a growing event history without losing the agent conversation.

Exercise

Problem

A ticket workflow has been open for six weeks. The customer has replied eleven times, the agent has taken about ninety turns, and every model call and tool call is an activity whose arguments and results are written to the event history.

The history is now 14 MB and 26,000 events. Temporal has been logging a warning since 10 MB, the hard limit is 50 MB or 51,200 events, and a workflow that reaches either is terminated with Workflow history size / count exceeds limit, not paused, not degraded. Terminated.

Long before that, replay gets slow: a worker that loses its cache has to read and re-apply the whole history before it can process the next turn, so a 26,000-event history means a cold worker takes seconds to answer a signal.

Forces

  • History grows monotonically and never shrinks within one execution.
  • Agent payloads are large. Prompts and completions are the biggest things most workflows write, which is why agents hit the size limit long before the event count.
  • The conversation must survive. The whole point of the entity workflow is that state persists across weeks.
  • Pending state cannot be silently dropped: an unfired timer, an unanswered approval, a signal that arrived microseconds ago.
  • Replay cost is paid on every cold start, not only at the limit.

Solution

Continue-as-new: atomically complete the current run and start a fresh one with the same workflow ID, a new run ID, and an empty history, carrying forward a compacted snapshot of the state.

   RUN 1  (workflow id: ticket-9104)          RUN 2  (same workflow id)
   ┌────────────────────────────────┐         ┌──────────────────────────┐
   │ 26,000 events · 14 MB          │         │ 0 events                 │
   │                                │         │                          │
   │ every prompt, completion,      │  ──▶    │ carried forward:         │
   │ tool result, timer, signal     │ CAN     │  · scratchpad (typed)    │
   │ since week 1                   │         │  · rolling summary       │
   │                                │         │  · budget counters       │
   │ ─────────────────────────────  │         │  · taint flag            │
   │ discarded (retained in         │         │  · pending approvals     │
   │ archival, not in the           │         │  · SLA deadline (absolute│
   │ running execution)             │         │    timestamp)            │
   └────────────────────────────────┘         └──────────────────────────┘
Same workflow ID, new run ID, empty history. What crosses is a snapshot, not a transcript.

Four rules:

Carry a snapshot, not a transcript. What crosses is the typed scratchpad, a rolling summary, and the counters. If you carry the full message list you have deferred the problem by one turn, and only while the transcript stays under 2 MB, because what crosses is a payload and payloads are capped like any other. Past that the continue-as-new is rejected, and the mechanism that was about to save the workflow is what fails it.

Trigger on measurement, not on a turn count. Check historySize and historyLength from the workflow info and continue when either crosses a fraction of the limit. A turn-count heuristic is wrong the first time someone pastes a large document.

Drain before you continue. Continue-as-new at a moment when a signal is in flight loses it. Check for buffered signals and pending activity results first, handle them, then continue. This is the single most common bug in implementations of this pattern.

Convert relative time to absolute. A timer for "four more days" is meaningless in a run that just started. The SLA deadline crosses as an absolute timestamp and the new run re-derives the remaining duration.

Code

ts/src/workflows/continue.ts
// Warn thresholds sit well below the 50 MB / 51,200-event hard limits.
const MAX_BYTES = 8 * 1024 * 1024;
const MAX_EVENTS = 8_000;

export interface CarryForward {
  scratchpad: Scratchpad;          // typed values, with provenance
  summary: RunningSummary;         // narrative, compacted
  budget: { spentMicros: number; turns: number };
  taint: RunTaint;                 // MUST cross — it is a security control
  slaDeadlineIso: string;          // absolute, never a remaining duration
  pendingApprovalIds: string[];
}

function shouldContinue(): boolean {
  const info = workflowInfo();
  return info.historySize > MAX_BYTES || info.historyLength > MAX_EVENTS;
}

export async function ticketAgent(carry: CarryForward): Promise<Outcome> {
  const state = restore(carry);

  while (!state.done) {
    await step(state);

    if (shouldContinue()) {
      // Drain first. Continuing with a signal in flight loses it — this is
      // the bug every implementation of this pattern has at least once.
      if (hasBufferedSignals() || hasPendingActivities()) continue;

      await compactIntoSummary(state);      // shrink what crosses
      return continueAsNew<typeof ticketAgent>(state.toCarryForward());
    }
  }
  return state.outcome();
}

The taint field crossing is not a detail. A taint flag lost at continue-as-new is a security control that silently stopped applying mid-run, the same failure as losing it at compaction, and it belongs in a failure-injection test for exactly that reason.

Trade-offs

Run IDs change; the workflow ID does not. Anything addressing the workflow by ID keeps working, which is why signal-with-start and approvals are unaffected. Anything that stored a run ID now points at a completed execution, so store workflow IDs, never run IDs.

Closing takes your children with it. Continuing-as-new is closing, and by default the service terminates every child workflow when its parent closes. This is easy to miss precisely because the pattern works: the first recycle may be weeks in, so a delegated child that ran fine for a month vanishes the moment the parent crosses its threshold. Set a parent-close policy of ABANDON on any child meant to outlive the current run. The other direction is safe and worth knowing, because it is what makes the parent-plus-recycling-child composition work at all: when a child continues-as-new, the parent treats the whole chain as one execution, and the handle it holds keeps pointing at it.

Debugging spans runs. The history you need may be two runs back. Tooling can follow the chain, and your trace should carry a stable identifier across continuations so an investigation does not stop at a boundary.

The compaction is lossy, and now it is also permanent. Whatever does not cross is gone from the running execution. It remains in the archived history and the trace, which is exactly why the scratchpad rather than the transcript is the thing that crosses, and why compaction rebuilds from it.

A continue-as-new loop is a real failure mode. If the carried state is itself over the threshold, the new run continues immediately, forever. Assert that the snapshot is materially smaller than the trigger, and alert if a run continues more than a few times in an hour.

When not to use it

Short workflows. A run that completes in twenty turns will never approach the limits. This is machinery for entity workflows measured in weeks.

When the entity should end instead. A ticket open for six months is possibly a process problem rather than a history problem. Continue-as-new keeps a workflow alive indefinitely, which is occasionally the wrong thing to make easy.

As a fix for oversized payloads. If one activity writes 1.5 MB to the history on every turn, continuing more often treats the symptom, and the day that payload reaches 2 MB it stops being a history problem at all and becomes a rejected write. Pass references instead. That is the tool-as-activity rule, and it is the actual fix.

When you need the full history in-execution. Some audit patterns want every event queryable on the live workflow. Continue-as-new moves old events to archival, so build the audit record deliberately rather than relying on the running history to hold it.

The same problem, twice, at two altitudes

An agent has two histories that grow without bound and two mechanisms for the same shape of fix.

The context window fills with turns, and compaction rebuilds it from typed state at a boundary. The event history fills with events, and continue-as-new rebuilds the execution from a snapshot at a threshold.

Both discard a transcript, both carry forward the verified state, and both fail the same way when the thing they carry is a summary of a summary rather than a record. That symmetry is why the structured scratchpad shows up in both answers: it is the only representation designed to survive being carried across a boundary.

On this page