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.
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:
- 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.