Skip to content
pleach
Architecture

Call classes

The four-class taxonomy every LLM call declares — utility, reasoning, converse, synthesize — with the lint that restricts the literal and the structural cap of one synthesize per turn.

Every LLM call in @pleach/core declares one of four call classes. The declared class drives three things: which seam carries the invocation, the per-turn allotment that applies, and the audit-row slice the call lands in. The literal is lint-restricted to the four seam factories, so a tool-loop node can't accidentally fire a synthesize call and break the rendered-equals-audited invariant.

The taxonomy is small on purpose. Four buckets are enough to distinguish "the runtime is making a routing decision" from "the user is reading this string," and that distinction is what the cost rollup, the matrix routing, and the singleton cap each key off.

CallClass is one of three concepts in the routing cluster — paired with Seams (the entry point each class binds to) and Family-lock (the session- locked column the seam resolves against). See Family-lock → the routing cluster for the cluster framing.

Subpath@pleach/coreSourcesrc/graph/seams/
type CallClass = "utility" | "reasoning" | "converse" | "synthesize"

The four classes

ClassPurposePer-turn allotmentTypical models
utilityInternal classification (tool-loop "call another tool or finish?" decisions, intent detection, planner routing, cache lookups)UnboundedCheap / fast
reasoningInternal generator feeding the next call (planner expansion, decomposition)UnboundedMid-tier
converseShort user-facing prose (refusal hints, retry narration)BoundedMid-tier
synthesizeThe final user-facing answerExactly one terminal per turn (a thin/empty first draft may be retried; exactly one row is marked terminal)Capable

In practice the four classes are not evenly used. utility (the tool-loop decision, fired many times per turn) and synthesize (the final answer, once per turn) carry the overwhelming majority of both call volume and cost — and because synthesize re-reads the whole turn context, it tends to dominate token cost even though it fires once. reasoning and converse are thinner, purpose-specific classes: they only fire when an agent has a distinct deliberation step (reasoning) or a short user-facing interstitial like a refusal or retry note (converse). Whether they carry meaningful volume depends entirely on how a given agent is wired — many agents route almost everything through utility + synthesize. That is expected; the taxonomy exists so the calls that do need a distinct model tier can declare it, not to force four equal streams of traffic.

utility

Internal classification calls. The runtime fires these to pick a branch, not to talk to the user. The canonical one is the tool-loop's continuation decision — "call another tool or finish?" — which runs on every step of an agentic turn. Other typical jobs: intent detection, planner routing, cache-bucket selection, tool-arg validation. The output is consumed by the graph, never rendered. Per-turn count is unbounded — a tool loop can fire as many utility calls as it needs to reach a decision. The matrix routes utility to the cheapest cell in the locked family.

Keeping the continuation decision in utility is what makes the synthesize cap structural: a utility call consumes a different seam and can't reach the synthesize counter, so the only call that can land the final answer is the synthesizer. See Seams — the singleton synthesize seam.

reasoning

Internal generators feeding the next call. The output is intermediate text that another node will consume — a plan expansion, a decomposition, an intermediate summarisation. Like utility, the user never sees it; unlike utility, the call expects coherent prose, so the matrix routes it to a mid-tier rung. Unbounded per turn.

converse

Short user-facing prose that is not the synthesis. Refusal hints, retry narration, "I need to clarify…" interstitials. The reader sees the output, so the matrix routes to a mid-tier model that can hold a voice, but the per-turn count is bounded — a turn that fires four converse messages before a final answer is broken, not chatty.

synthesize

The final user-facing answer. Exactly one terminal synthesis per turn — the one row that lands the answer the user sees. The matrix routes to a capable rung; the constraint is structural, not advisory.

A turn MAY make more than one synthesize call: if the first draft comes back thin or empty, the synthesizer fires a bounded, in-stage recovery retry. Each call records its own append-only audit row, but at most one row carries synthesisQuality.terminalSynthesis: true — the final answer. It is exactly one on a synthesized turn, and zero on a passthrough that accepts the model's first draft as-is. The invariant is therefore count(terminalSynthesis === true) <= 1 (never two), not "one synthesize call."

Why exactly one terminal synthesis per turn

The user sees the synthesis. If the runtime marked two rows terminal and rendered one of them, the ledger would say one thing and the UI another. Marking exactly one row terminal means the rendered string and the terminal-audited string are the same string — even when a thin first draft was retried.

SynthesizeSeamHolder (per-runtime singleton) and TurnSynthesizeCounter (idempotent on messageId) bound how many synthesize calls a turn makes. They do not, on their own, bound how many audit rows get flagged terminal — the flag is written at the ledger write sites, and a terminal reached without a seam invoke is outside what a call counter can see. Treat the two as separate guarantees: one caps calls, one caps the flag.

A recovery retry for a thin/empty draft DOES append its own audit row — the ledger is append-only — but only the row that lands the final answer is flagged terminal.

count(terminalSynthesis === true) <= 1 passes at zero, and zero is a real failure direction, not a benign one: if the row that would have carried the flag is dropped before it reaches the ledger, the turn ends with no terminal-marked row and the delivered answer is no longer identifiable in the audit. An atMostOneTerminalSynthesis check cannot see that.

Assert exactlyOneTerminalSynthesis on turns you expect to synthesize, and keep the atMost form only where zero is genuinely correct — a pure passthrough or a conversational turn that makes no synthesize call at all. Counting terminal-marked rows rather than raw synthesize rows is necessary but not sufficient; the bound has to be two-sided.

See Seams — the singleton synthesize seam for the invariant in full.

The callClass lint

The literal strings "utility", "reasoning", "converse", "synthesize" are restricted to the four seam factories in src/graph/seams/. Anywhere else in the codebase, callClass: "..." fails the lint gate lint:callclass-literals.

Outside the seams, code reaches a model through AgentAdapter.resolveModel<C>() — the locked class travels as a type parameter, not a runtime string.

// Inside a seam factory — allowed:
export function synthesizeSeam(/* ... */): ProviderSeam<"synthesize"> {
  return makeSeam({ callClass: "synthesize", /* ... */ })
}

// Inside a node — fails lint:callclass-literals:
const result = await adapter.resolveModel({ callClass: "synthesize" })

// Inside a node — passes:
const result = await adapter.resolveModel<C>()

Why a lint, not a runtime check? A runtime string lets any node accidentally pick synthesize and slip past the singleton seam, and the structural cap goes from "guaranteed by construction" to "hopefully nothing reached around it." The lint catches the escape at the seam boundary. See Seams for the four factories.

When you do need the value rather than the type — a new seam factory, a test asserting which class a node resolved — import the constant instead of retyping the literal:

import {
  UTILITY_CALL_CLASS,
  REASONING_CALL_CLASS,
  CONVERSE_CALL_CLASS,
  SYNTHESIZE_CALL_CLASS,
} from "@pleach/core/graph/seams/callClassConstants"

That is the one import the lint is designed to leave alone: it names the class without minting a fresh literal for the gate to catch.

callClass on the audit row

Every AuditableCall row carries call.callClass. Per-turn cost rollup by class is one GROUP BY:

SELECT call_class, SUM(token_cost)
FROM harness_auditable_calls
WHERE turn_id = $1
GROUP BY call_class

A synthesize-class row that fires twice in the same turnId is the canonical "the cap leaked" alert — the query is one predicate. See The AuditableCall row for the full row shape and the other slices it supports.

callClass in the fingerprint

callClass participates in the cache key. Different classes resolve to different models in the matrix, so a utility call and a synthesize call on otherwise-identical inputs sit in different equivalence classes — the cache scope reflects the routing.

Concretely: replaying a utility call doesn't return a cached synthesize result that happened to share the same prompt. The class is part of the equivalence class, so the cache only collapses calls the matrix would route the same way.

See Fingerprint for the full key/metadata split.

How call class flows through a node

A node declares acceptsSeam: CallClass | null in its metadata. The substrate binds the matching seam at compile time and hands the node a ProviderSeam<C> where C is the declared class. Outside the substrate, the literal never appears.

const synthesizerMeta = {
  stageId:     "synthesize",
  acceptsSeam: "synthesize",
  subscribes:  ["plan", "toolResults"],
  writes:      ["finalMessage"],
}

A node with acceptsSeam: null is a seam-free state transform — no LLM call, no class to declare. See Nodes for the full node-metadata contract.

Where to go next