pleach
Architecture

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.

Subpath@pleach/core/strategiesSourcesrc/strategies/streamSingleTurn/turnBody.ts

Where 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

On this page