streamSingleTurn
The canonical per-turn body — what runs between executeMessage and the final stream frame. Consumes the typed strategy slots and OrchestratorClient.
streamSingleTurn is the per-turn body. It owns the path from
"the runtime entered a turn" to "the synthesis stream closed" —
provider call, tool dispatch, continuation gate, fallback
chain, hallucination check, repetition guard, synthesis. The body
is generic substrate; everything host-specific exits through
typed strategy slots and plugin hooks.
The body lives in the package's internal
strategies/streamSingleTurn/ tree — an implementation detail, not a
public import surface — and is the
default body the runtime threads through when a host constructs
SessionRuntime without a custom strategy. This is its current
home as of the PO-A1 lift (commit 0a1fde560); previously it
lived at src/lib/orchestrator/streamSingleTurn.ts on the host
side. Hosts that need a non-default body register one through
the module-loader seam — the runtime resolves the body via
dynamicImportApp("orchestrator.streamSingleTurn") (wired with
setHarnessModuleLoader), so a registered host body replaces the
default at that key.
@pleach/core/strategiesSourcesrc/strategies/streamSingleTurn/turnBody.tsWhere the body sits
streamSingleTurn is one of two execution paths the runtime
exposes. The body runs the imperative arc — call provider,
dispatch tools, continuation-or-synthesize, write the ledger — and
hands the OrchestratorClient handle
to every strategy slot and plugin hook it consumes. The
declarative graph path walks the four-stage lattice
when the turn shape needs the enrichment slots. Both paths write
the same AuditableCall rows and honor the same family-lock; the
choice is per-host, not per-turn.
A turn enters the body through runtime.executeMessage(sessionId, content). The runtime allocates a per-turn OrchestratorClient,
resolves the registered strategy, and dispatches. The body
streams StreamEvent frames as it runs; the runtime forwards
them to the SSE consumer. See
Turn lifecycle for the wall-clock arc and
Stream events for the per-frame shape.
What the body consumes
The body is a generic per-turn driver. Every host-specific
behavior — model fallback, hallucination detection, repetition
budgeting, continuation gate exemptions, runaway-directive
carry-forward — enters through optional harnessRuntime?.getX()
accessors on OrchestratorConfig.harnessRuntime. A host that
wires zero accessors gets a working body: every read chains with
?. and falls back to a no-op default and the substrate emits
its own baseline behavior.
The body reads four such accessors today — the C1–C4 dep-inversion cohort landed alongside the PO-A1 body lift. Each read sits inline at the point of use:
| Accessor (read site) | What the body asks it |
|---|---|
harnessRuntime?.getDomainContextStrategy?.() (C1; turnBody.ts ~L1506, L1787, L2119) | Per-turn domain-context strategy supplying the science-context / short-content-garble predicates the body's degradation guards consume. Default: no-op strategy. |
harnessRuntime?.getHallucinationDetectorFactory?.() (C2; turnBody.ts ~L835, L1795) | Build a per-turn detector. Body calls feed(chunk) on every content delta and reads hallucinationDetected / narratedWithoutExecution after the turn settles. Default: createNoOpHallucinationDetector. |
harnessRuntime?.getRepetitionGuard?.() (C3; turnBody.ts ~L419) | Per-session repetition tracker. Body calls setPlanActive, getPriorTurnRunawayDirective, clearPriorTurnRunawayDirective if defined. Default: no-op; the runaway-directive arm stays silent. |
harnessRuntime?.getContinuationMetaToolNames?.() (C4; turnBody.ts ~L410) | Names exempted from tool-call counting in the depth-zero continuation gate. Default: empty set; every tool counts. |
EMPTY_SINGLE_TURN_FLAGS from @pleach/core/types/singleTurn is
the four-boolean reset baseline (truncated, modelFallback,
contextOverflow, hallucinationDetected) that the body spreads
at turn boundaries.
The accessor table above is the body-facing view of the surface
documented in full at
Runtime strategies § Newer injection hooks.
The full strategy inventory — summaryExtractor, toolCatalog,
subagentExecutor, and the other singletons — lives in the same
page.
Generic-by-injection
The body itself contains no host-specific logic. Domain behavior
(host vocabulary, vendor backends, sandbox tool prefixes, domain
phrasing — for example a host's domain-specific input handling,
third-party integrations, or safety directives) arrives via
harnessRuntime.collectX() and harnessRuntime.getX() calls
into the registered plugin surface. The body asks; the plugin
answers; nothing is hardcoded. This is the property that lets
the same body drive arbitrary domains.
What the body never sees
The body is generic substrate; the
domain-string purity gate
asserts it stays generic. Five categories of literal are
forbidden from packages/core/src/** and therefore from the body
itself:
- Host vocabulary (the consumer's domain terms)
- Vendor backend names (specific cloud / execution platforms)
- Sandbox tool prefixes (the host's tool namespace convention)
- Identity discriminators (host-specific account or org strings)
- Domain phrasing (host-specific prompt fragments)
A leak fails audit:domain-string-purity in CI. The body stays
domain-agnostic by structure, not by review discipline. Plugins
remain the only legitimate channel for any of the five
categories; the body asks the plugin surface for the value it
needs and ships none of it inline.
See Language-agnostic contract § How the contract stays honest for why the gate is load-bearing.
The helpers barrel (internal)
streamSingleTurn groups its helpers behind a barrel in the
package's internal strategies/streamSingleTurn/helpers/ tree. This
barrel is an implementation detail — not part of the public API.
It sits under the package's internal/ namespace precisely to signal
that its paths can move or change shape between releases with no
deprecation cycle. Do not import it directly.
The barrel groups both the typed contracts the body consumes —
TurnAccumulator, OrchestratorMessage, ProviderFallbackConfig,
ToolDefinition — and a small set of runtime utilities the body
uses inline (safeStringifyArgs, parseToolArgs,
dispatchForceSynthesisEchoDetector,
buildImperativeProviderAttemptCallback). They exist for the body's
own use, not for consumer code.
For the public, supported types a strategy needs, reach for the
named type subpaths instead. The generic ToolDefinition structural
contract, for example, ships from @pleach/core/types/generic:
import type { ToolDefinition } from "@pleach/core/types/generic";A host does not import the body's internal helpers to wire a custom strategy. It registers a replacement body through the module-loader seam (see Wiring a custom body) and consumes the typed strategy slots the runtime threads in.
Consumer-surface non-overlap
The body lives at turnBody.ts and the chunk-time observer
cohort lives at streamSingleTurn/consumers/*. These are locked
as disjoint import territories: a single file may
import from one or the other, never both. audit:streamSingleTurn-consumer-surface
runs at PR time and fails any host file that overlaps the two.
If a host genuinely needs both, instantiate two separate
consumer surfaces — one for the body, one for the chunk-time
observers — and keep their import graphs disjoint.
Wiring a custom body
The default streamSingleTurn body handles the linear-turn case
without configuration. A host that needs a different shape —
typically because the turn needs to interleave a custom planner
or a non-standard tool-dispatch order — registers its own body at
the orchestrator.streamSingleTurn module-loader key. The wiring
goes through setHarnessModuleLoader (see
Host adapter), not a SessionRuntimeConfig
field; the runtime resolves the registered body via
dynamicImportApp("orchestrator.streamSingleTurn") and threads the
same per-turn OrchestratorClient into it.
A custom body is a sharp tool. The default body is what the substrate's parity tests run against — replacing it is opting out of the byte-replay property until the custom body passes the same fixtures. See Determinism for the five contracts the default body holds.
Where to go next
Runtime strategies
The full strategy slot inventory the body consumes.
OrchestratorClient
The per-turn handle the body threads into every strategy slot.
Turn lifecycle
The wall-clock arc of one turn — what the body does between executeMessage and the final SSE frame.
Plugin contract
The contribution surface that supplies the body's domain inputs.
Stream events
Every event type executeMessage yields — message deltas, tool lifecycle, jobs, interrupts, checkpoints, sync, errors. The full catalog with payload shapes.
Interrupts
Pause a turn for human approval — the `HumanInterrupt` envelope, response shapes, per-tool approval, and how interrupts compose with the stream.