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.
| 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
@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 === 5Pass 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:
- The first call inside a turn sets
max: 1. - A nested sub-agent call sets
max: 5. - The counter's idempotency-on-
messageIdinvariant (D-37) becomes ambiguous — whichmaxwins?
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
SessionRuntimeboot the field is read — but a counter that doesn't exposesetMaxPerTurnsilently ignores the value and stays at the D-50 baseline of one synthesize per turn. - A host that supplies a counter implementing
setMaxPerTurnsees 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.
SynthesizeSeamHoldercontinues to serve a singleProviderSeam<"synthesize">perSessionRuntime. The cap governs how many times the seam is invoked per turn, not how many seams exist. - Idempotency on
messageId. The counter remains idempotent onmessageIdregardless of the cap value (D-37). A duplicate-delivery retry of the samemessageIddoes 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.invokedprojection row. The audit ledger gets one row per call; themessageIdfield 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 samechatIdandmessageId. - Replay.
@pleach/replayre-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
| 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
File tools
The 8 file/diff/exec tools the multi-synth loop drives.
Long session context
Roadmap — the context-budget knob that pairs with multi-synth.
Sandbox providers
The provider + composite surface multi-synth flows reach for.
Call classes
The synthesize / reasoning / utility / converse axis multi-synth lives on.
File tools
The eight vendor-neutral file / diff / exec tool factories — read_file, write_file, apply_diff, search_files, list_files, run_tests, git_clone, git_diff.
Long session context
ContextStrategy eviction factories (createLruContextStrategy, createImportanceContextStrategy) for file-context budgets in long-running coding sessions. Strategy factories shipped; the runtime-side CodingContextManager is roadmap.