Skip to content
pleach
Coding agent

Multi-synthesize per turn

The per-runtime maxSynthesizePerTurn knob — contract widened in @pleach/core, default policy shipped in @pleach/coding-agent. Host support depends on the counter implementation.

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.

Canonical chat enforces one synthesize-class seam invocation per turn (D-50). 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

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.

ValueBehaviorUse case
1One synthesize per turn — D-50 default.Canonical chat.
5Up to five synthesizes per turn. Soft cap matching the plan → diff → verify → retry → verify pattern.Coding agent default.
InfinityNo 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

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

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.

The generic factory — createMultiSynthesizePolicy

createCodingAgentPolicy is the opinionated default (cap 5) over a generic factory. createMultiSynthesizePolicy(config?) — also on @pleach/coding-agent/policy — is the bare knob with no baked-in default. It returns the same SessionRuntimeConfigPolicy interface: a non-mutating apply(config) that stamps maxSynthesizePerTurn on a new config object.

import { createMultiSynthesizePolicy } from "@pleach/coding-agent/policy";

const policy = createMultiSynthesizePolicy({ maxPerTurn: 5 });
const config = policy.apply({});
// config.maxSynthesizePerTurn === 5

Pass perCallClassMax to extend the cap to other call classes; its synthesize slot supersedes maxPerTurn when both are set (narrowest cap wins). Omit maxPerTurn and apply() leaves the field untouched, so a later middleware — or the runtime's own default — can stamp it.

Layered per-CallClass routing

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

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);

apply() stamps the map on SessionRuntimeConfig.perCallClassModelRouting, and @pleach/core honours it. SessionRuntime reads the field once at construction and applies it where every seam gets its model (adapter.resolveModel), passing the routed id as the call's modelOverride. Routes take effect on adapters that implement that override: core's createProviderAdapter and the quickstart standard adapter both do. A third-party adapter that doesn't keeps its own model, and the decision is recorded as not-honored.

Routes never widen across families. A route whose model belongs to another family, whose family can't be derived from the id, or that arrives before the session's family lock is observable is refused: the call runs as if unrouted, a [PerCallClassRouting] override-refused probe fires, and the decision lands on auditMetadata.perCallClassRouting. An in-family recovery override on a single call still wins over the configured route. See Per-call-class overrides for pricing and the custom-stream contract.

On a @pleach/core that predates the field, the map is recorded on the config but ignored.

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

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

  • 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

  • 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'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 re-derives the full N-call sequence deterministically. The replayed turn's tool-call count and synthesize-call count both match the recorded turn.
IDLocked?Summary
D-50YesSingleton synthesize seam per SessionRuntime.
D-37YesTurnSynthesizeCounter idempotent on messageId.
D-72YesSingleton 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