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.

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

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

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

On this page