# Multi-synthesize per turn (/docs/coding-agent/multi-synthesize)



<Callout type="warn">
  **Status: shipped — partial.** The contract widening landed in
  `@pleach/core`, and the consumer-facing policy + facade landed in
  `@pleach/coding-agent`. Whether a given host honors
  `maxSynthesizePerTurn` depends on its `TurnSynthesizeCounter`: a
  counter that implements `setMaxPerTurn` gets the multi-synthesize
  behavior end-to-end; a counter that doesn't stays at the D-50 cap of
  one synthesize per turn and ignores the field.
</Callout>

Canonical chat enforces one synthesize-class seam invocation per turn
([D-50](/docs/decisions)). Coding agents don't fit that mold: a
`plan → diff → verify` flow typically issues three synthesize calls
inside a single turn, and a `plan → diff → verify → retry → verify`
flow issues five.

`maxSynthesizePerTurn` is the per-runtime knob that opts a runtime
into multi-synthesize. It preserves the D-50 invariant by construction
— the same singleton-seam contract is still in effect; the counter
just allows more invocations against it.

## The contract [#the-contract]

```ts
import type { SessionRuntimeConfig } from "@pleach/core";

interface SessionRuntimeConfig {
  // ...existing fields
  readonly maxSynthesizePerTurn?: number; // default 1 (D-50 baseline)
}

interface TurnSynthesizeCounterContract {
  shouldAllowSynthesize(messageId: string): boolean;
  // ...existing surface
  setMaxPerTurn?(max: number): void; // optional widening
}
```

The field lives at the runtime-config layer ONLY. There is no per-call
override — multi-synth opt-in is per-runtime by design. Per-call
overrides would re-introduce the failure mode D-50 was carved to
prevent: silent counter resets mid-turn from a callsite that didn't
know it was inside a budget-bounded loop.

`setMaxPerTurn` on the counter is intentionally OPTIONAL. Host runtimes
opt in by implementing the method; runtimes that don't implement it
keep the D-50 baseline of one-per-turn regardless of what the config
field says. The optional shape is the back-compat carve-out — adding
the field doesn't force every existing counter implementation to be
re-built.

| Value      | Behavior                                                                                                | Use case                                                                          |
| ---------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `1`        | One synthesize per turn — D-50 default.                                                                 | Canonical chat.                                                                   |
| `5`        | Up to five synthesizes per turn. Soft cap matching the `plan → diff → verify → retry → verify` pattern. | Coding agent default.                                                             |
| `Infinity` | No per-turn cap. The counter still emits events; only the rejection branch is disabled.                 | Research / SWE-Bench-style runs where convergence is the only stopping condition. |

## The coding-agent policy [#the-coding-agent-policy]

`@pleach/coding-agent` ships an opinionated default that sets
`maxSynthesizePerTurn` to 5 — the canonical reflection-loop cap.

```ts
import { createCodingAgentPolicy } from "@pleach/coding-agent/policy";

const config = createCodingAgentPolicy({
  maxSynthesizePerTurn: 5,
}).apply({
  // ...rest of SessionRuntimeConfig
});

// `config.maxSynthesizePerTurn === 5` — picked up by a setMaxPerTurn-
// capable TurnSynthesizeCounter at runtime boot.
```

The factory returns a `SessionRuntimeConfigPolicy` — a `.apply(base)`
shape that composes cleanly with sibling policies. `createCodingAgentPolicy({})`
without arguments still applies the coding-agent default of 5.

### Layered per-CallClass routing [#layered-per-callclass-routing]

`@pleach/coding-agent` also ships a sibling policy for per-CallClass
model overrides that layers on top of `createCodingAgentPolicy`:

```ts
import {
  createCodingAgentPolicy,
  createPerCallClassRouting,
} from "@pleach/coding-agent/policy";

const base = createCodingAgentPolicy({ maxSynthesizePerTurn: 5 }).apply({});
const routed = createPerCallClassRouting({
  routes: {
    synthesize: "anthropic/claude-sonnet-4-5",
    utility: "anthropic/claude-haiku-4-5",
  },
}).apply(base);
```

The routing map is recorded structurally on the config; a follow-up
core slice will widen `SessionRuntimeConfig.perCallClassModelRouting`
to the canonical shape and `RoutingDecisionEngine` will consume it.
Forward-compatible today; honored end-to-end once the core widening
ships.

## Why per-runtime only [#why-per-runtime-only]

The counter is owned by the `TurnSynthesizeCounter` impl, which is
constructed once at `SessionRuntime` boot and passed into
`SynthesizeSeamHolder.init({...})`, which the synthesizer node consumes
through the holder. The tool-loop's `createLlmDecisionNode` is
`utility`-class and consumes a separate seam, so it never touches this
counter — the singleton is per call class (D-72).

A per-call override would mean:

1. The first call inside a turn sets `max: 1`.
2. A nested sub-agent call sets `max: 5`.
3. The counter's idempotency-on-`messageId` invariant (D-37) becomes
   ambiguous — which `max` wins?

Per-runtime opt-in keeps the resolution flat: the counter knows its
cap at boot, the cap doesn't change for the runtime's lifetime, and
the per-`messageId` idempotency contract stays simple.

## Host support is conditional [#host-support-is-conditional]

The field is honored only by a `TurnSynthesizeCounter` that implements
the optional `setMaxPerTurn` method:

* A `createCodingAgentPolicy({ maxSynthesizePerTurn: 5 })` configuration
  always produces a config object with the field set.
* At `SessionRuntime` boot the field is read — but a counter that
  doesn't expose `setMaxPerTurn` silently ignores the value and stays
  at the D-50 baseline of one synthesize per turn.
* A host that supplies a counter implementing `setMaxPerTurn` sees the
  multi-synthesize behavior end-to-end.

The host wiring is small — one method on `TurnSynthesizeCounter` plus a
constructor read of `config.maxSynthesizePerTurn`.

## What stays invariant [#what-stays-invariant]

* **One seam per runtime.** `SynthesizeSeamHolder` continues to serve a
  single `ProviderSeam<"synthesize">` per `SessionRuntime`. The cap
  governs *how many times* the seam is invoked per turn, not how many
  seams exist.
* **Idempotency on `messageId`.** The counter remains idempotent on
  `messageId` regardless of the cap value (D-37). A duplicate-delivery
  retry of the same `messageId` does not consume a budget slot.
* **D-72 (singleton per call class).** The synthesizer node — and the
  recovery path — are the only consumers of the synthesize seam; the
  tool-loop decision is `utility`-class on its own seam. Multi-synth
  raises the per-turn invoke cap but doesn't change which node class
  reaches the seam.
* **Family-strict cascade pivot.** When a synthesize call exhausts its
  in-family rungs, the cascade still emits
  `[UXParity:family-exhausted]` and surfaces the user-facing
  family-pick affordance. Multi-synth doesn't silently widen
  cross-family.

## What changes downstream [#what-changes-downstream]

* **Event log.** Each synthesize invocation surfaces a distinct
  `synthesize.invoked` projection row. The audit ledger gets one row
  per call; the `messageId` field disambiguates calls within a turn.
* **Cost attribution.** [`@pleach/observe`](/docs/observe)'s per-call
  cost attribution captures each synthesize independently — a 5-call
  turn surfaces as 5 rows tagged with the same `chatId` and
  `messageId`.
* **Replay.** [`@pleach/replay`](/docs/replay) re-derives the full
  N-call sequence deterministically. The replayed turn's tool-call
  count and synthesize-call count both match the recorded turn.

## Related decisions [#related-decisions]

| ID   | Locked? | Summary                                                                                                                                                 |
| ---- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| D-50 | Yes     | Singleton synthesize seam per `SessionRuntime`.                                                                                                         |
| D-37 | Yes     | `TurnSynthesizeCounter` idempotent on `messageId`.                                                                                                      |
| D-72 | Yes     | Singleton is per call class — only the synthesizer (and recovery) consume the synthesize seam; the tool-loop decision is utility-class on its own seam. |

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="File tools" href="/docs/coding-agent/file-tools" description="The 8 file/diff/exec tools the multi-synth loop drives." />

  <Card title="Long session context" href="/docs/coding-agent/long-session" description="Roadmap — the context-budget knob that pairs with multi-synth." />

  <Card title="Sandbox providers" href="/docs/coding-agent/sandbox-providers" description="The provider + composite surface multi-synth flows reach for." />

  <Card title="Call classes" href="/docs/call-classes" description="The synthesize / reasoning / utility / converse axis multi-synth lives on." />
</Cards>
