Skip to content
3 min read · 616 words

Turn-scoped pipelines

TL;DR

Think of these two as the bookends of a turn. The input pipeline runs once before the model is dispatched — it assembles everything the model is about to see. The output pipeline runs once after the dispatch is done — it handles whatever has to happen after the turn has settled (post-hoc safety, memory updates, telemetry).

The cost rule: anything you put here runs once per turn. A ten-iteration dispatch does not multiply this work. That's the whole reason these pipelines exist as a separate scope from dispatch-scoped ones.

Both turn-scoped pipelines see the TurnContext. Neither sees the dispatch primitives — those live on DispatchContext and are only visible inside the dispatch loop.

turnInputPipeline

Turn-scoped. Fires once, before dispatch. Context: TurnContext.

The turn just arrived. ctx.turnMessages, ctx.turnMemories, ctx.turnRetrievables are empty. ctx.tools contains whatever you configured on the runner (or what fetchTools resolved). The executor sees exactly what middlewares in this pipeline put on the turn collections. Empty turnInputPipeline → empty context → the model responds to nothing. That is the default. The model gets the silence you ship.

  • Retrieve Retrievable records into ctx.turnRetrievables. Declare Retrievable.trustTier at the source — the prompt battery uses it to pick the envelope. See Trust Tiers.
  • Load Memory records. Score, filter, write into ctx.turnMemories.
  • Pack history. await ctx.fetchMessages(), decide how much to surface, trim to a budget. See Budgets.
  • Enforce inbound policy. Refuse turns before the model sees them. A throw wraps as E_INPUT_PIPELINE_ERROR; dispatch and output are skipped, turnEnd still fires. Intentional refusal uses ctx.abort(reason) — see Abort.

Turn-level cost belongs here. Move retrieval into dispatchInputPipeline and you pay for it every iteration — ten iterations, ten bills. Work that does not depend on what the model just said does not belong in the dispatch loop.

turnOutputPipeline

Turn-scoped. Fires once, after dispatch. Context: TurnContext.

The dispatch is done. In the typical setup the executor invoked tool handlers during dispatch (that is what made the iteration loop useful — the next iteration's model call saw the results), and the persistence callbacks have already fired for whatever the executor stored via ctx.storeMessage / storeThought / storeToolCall. What turnOutputPipeline owns is post-hoc turn work: anything that has to happen once after the dispatch has settled, not a second handler invocation.

  • Post-hoc safety. await ctx.fetchMessages() (and the matching fetchThoughts / fetchToolCalls if you wire them) to see what landed in storage this turn, then ctx.mutateMessage(...) / mutateThought / mutateToolCall to rewrite or annotate. Cheaper than refusing in dispatchOutputPipeline if your check needs the full record assembled.

Do not trust ctx.turnMessages here as your only source of truth

In early-ack paths the record was persisted, but the parent Set may not show it. Fetch from storage when the check depends on the completed record. The Set is a convenience; storage is the receipt.

  • Update memories. ctx.storeMemory(...) / ctx.mutateMemory(...) — memories that should reflect what the whole turn said, not what a single iteration produced.
  • Turn telemetry. ctx.stash from turnInputPipeline is still here — turn-scoped fields survive the dispatch loop unchanged.

A throw from any middleware in this pipeline wraps as E_OUTPUT_PIPELINE_ERROR and skips the remaining downstream middlewares. Order the pipeline so the most important writes happen first, and let post-steps do the cleanup — Throws covers the contract.

This pipeline only runs on ack. A nack or abort skips it (turnEnd still fires). If you put required cleanup or failure reporting here, you just made it success-only by accident.

Where to go next