Skip to content
pleach
Architecture

Graph

The declarative topology that drives a turn — Annotation channel schema, StateGraph builder, the Send envelope, compile() runner, and the four-stage lattice gate that keeps every node attributable.

The runtime substrate is a declarative graph. Nodes consume from typed channels and write to typed channels; the engine schedules nodes reactively based on what advanced since the last superstep. compile() returns the runner that SessionRuntime.executeMessage drives. The six-pieces diagram in Architecture names the graph as the second piece; this page documents the API surface.

Graph is one of three concepts in the execution-graph cluster — graph, nodes, channels. See Architecture → the execution-graph cluster for the cluster framing; the other two pages cover the same substrate from the node and channel sides.

Channel kinds (the typed state slots nodes read and write) live at Channels. The node-level metadata shape — stage membership, seam declaration, authoring a custom node — lives at Nodes. This page is about the graph as a whole.

Subpath@pleach/core/graphSourcesrc/graph/
import {
  StateGraph,
  CompiledGraph,
  Annotation,
  AnnotationRoot,
  Send,
  isSend,
} from "@pleach/core"

import type { StreamEvent } from "@pleach/core"
import type { DefaultAgentState } from "@pleach/core/graph/defaultAgentState"
import { buildDefaultAgentGraph } from "@pleach/core/graph/defaultAgentGraph"

Six graph symbols have no published import path

START, END, DEFAULT_TURN_FLAGS, StateGraphNodeMetadata, AnnotationSchema, and InferAnnotationState are declared in @pleach/core and re-exported from its internal graph/index.ts, but ./graph is not a key in the published exports map — so from "@pleach/core/graph" throws ERR_PACKAGE_PATH_NOT_EXPORTED on a fresh install, and the named graph/* entries don't carry them either. Until a barrel lands (pleachhq/core), use the string literals "__start__" / "__end__" that START and END alias.

Three pieces

Annotation — channel schema

Annotation<T> declares one channel's default and reducer. Annotation.Root(schema) collects them into a typed schema — it's the factory; AnnotationRoot is the class it constructs, not a callable. The engine reads the schema at compile time to instantiate the underlying Channel<T> instances — the same six kinds documented at Channels.

import { Annotation, messagesReducer } from "@pleach/core"
import type { Message } from "@pleach/core"

const StateSchema = Annotation.Root({
  messages: Annotation<Message[]>({
    reducer: messagesReducer,
    default: () => [],
  }),
  intent: Annotation<string>({
    default: () => "unknown",
  }),
})

type State = InferAnnotationState<typeof StateSchema>

The schema is the typed contract every node reads against. A node that declares subscribes: ["messages"] in its addNode metadata is scheduled whenever that channel advances; writes declares the channels its returned patch may touch.

StateGraph — declarative builder

StateGraph is the builder. .addNode(name, fn, metadata?) registers a node; .addEdge(from, to) wires a static edge; .addConditionalEdges(from, routeFn, destinations) wires a branching edge — all three arguments required; .compile() returns a CompiledGraph.

The metadata argument is NodeMetadata — { subscribes?, writes?, retryPolicy?, passiveProjector? }. The stageId / acceptsSeam declarations documented at Nodes live on the separate *_NODE_METADATA constants the lattice gate reads, not on this argument.

const graph = new StateGraph(StateSchema)
  .addNode("agent", agentNode, {
    subscribes: ["messages"],
    writes: ["messages"],
  })
  .addNode("tools", toolsNode, {
    subscribes: ["messages"],
    writes: ["messages"],
  })
  .addEdge(START, "agent")
  .addConditionalEdges("agent", routeAfterAgent, {
    call_tools: "tools",
    done: END,
  })
  .addEdge("tools", "agent")
  .compile()

The two-node loop above is the canonical agent-plus-tools shape. The agent node decides; the route function reads the latest message and returns call_tools or done; the tools node executes pending tool calls and writes results back; control returns to agent. The loop exits when the agent emits a final message with no tool calls.

Send — a fan-out envelope, not yet engine-wired

Send names a target node plus a state slice, and isSend is its runtime guard. Both are exported from the root barrel — but the engine does not consume them. The core barrel says so itself: conditional-edge route functions are typed (state) => string | string[], so a returned Send cannot reach routing, and isSend has no production call sites.

import { Send, isSend } from "@pleach/core"

// Consumer-composable only: build Send envelopes inside YOUR node
// body (e.g. paired with dispatchPerTool output) and dispatch them
// yourself. Returning them from a route function does nothing.
const envelopes = pendingCalls.map((call) => new Send("tool_runner", { call }))

The engine dispatches one dynamic instance of the target node per Send, inside the producing superstep. Each instance sees its per-Send input as a scoped overlay on the frozen channel snapshot — never a channel write — with the raw payload riding __sendInput. Only what the target returns folds into state, through its declared channel reducers, in Send-index order rather than completion order. That fold order is what keeps fan-out replay-deterministic at any concurrency.

Three constraints, each enforced with a hard error instead of a silent drop:

  • Same-stage only, and only in anchor-plan and tool-loop. A Send bypasses subscription routing, so cross-stage fan-out is refused. synthesize targets are refused too — dynamic instantiation would bypass the singleton per-turn cap — and so are post-turn targets, because that stage holds durable-side-effect writers that fan-out would double-write. Graphs whose nodes carry no stage mapping on either side are admitted; the engine stays agnostic for synthetic graphs.
  • Capped per superstep. maxSendsPerSuperstep (default 16, the SEND_FANOUT_CAP_ENGINE_DEFAULT constant) bounds total dynamic instantiations in one superstep. A target's own Sends join the same superstep's queue, so a Send loop hits the cap and throws rather than spinning.
  • Unwired paths fail loud. A Send[] returned on the sequential fallback path (metadata-less graphs) throws, and a Send[] returned by a post-terminal node is refused with an error naming the producing node. Command routing remains detection-only — not wired into the engine.

isSend(value) is the runtime guard. Use it when a route function returns a heterogeneous union (a string for static targets, a Send array for fan-out) and downstream code needs to discriminate.

Edges

Two edge kinds wire the topology together:

Edge kindBuilder callPicks the next node by
Static.addEdge(from, to)Always to after from completes
Conditional.addConditionalEdges(from, routeFn, destinations)The route function's return value, mapped through destinations

START and END are the implicit endpoints. addEdge(START, "agent") declares the entry node; addEdge("done", END) (or returning END from a route function) terminates the graph for that turn.

The route function signature is (state) => string | string[] (PathFn in StateGraph.ts — no Send overload). The third argument, destinations: Record<string, string>, is required: it translates the returned label to a node name, and that indirection keeps route functions testable without holding the graph reference.

graph
  .addEdge(START, "anchor")
  .addEdge("anchor", "agent")
  .addConditionalEdges(
    "agent",
    (state) => (state.messages.at(-1)?.tool_calls ? "tools" : "synth"),
    { tools: "tools", synth: "synth" },
  )
  .addEdge("tools", "agent")
  .addEdge("synth", "post")
  .addEdge("post", END)

The lattice admits nine (from-stage, to-stage) edge patterns: the four happy-path transitions (anchor-plan → tool-loop, tool-loop → synthesize, synthesize → post-turn, and the post-turn → anchor-plan next-turn rollover), the tool-loop → post-turn recovery-dispatch edge, the bounded synthesize → tool-loop re-entry (at most once per turn), and the three intra-stage chains — anchor-plan, tool-loop, post-turn. synthesize is the only stage with no intra-stage edge: the synthesize → synthesize retry self-loop is retired and forbidden. Every other pair is forbidden. See Architecture § stage lattice for the structural constraint and the audit gate that enforces it.

CompiledGraph

.compile() returns a CompiledGraph — the runner. It carries the schedule (which node fires when which channel advances) plus the instantiated channel set, accepts a per-turn input, streams StreamEvents, and returns when a terminal node edges to END.

You usually don't invoke a CompiledGraph directly. SessionRuntime.executeMessage owns it and drives the iteration — see Turn lifecycle for the call arc. The compiled graph is exposed on the runtime for tooling that inspects the topology (devtools, the audit:graph-stages script, replay harnesses).

Engine guarantees

The runner under CompiledGraph is SuperstepRunner, exported — with RetryPolicy and SuperstepNode — from @pleach/core/engine. Four guarantees ride on it:

  • Convergence detection. The runner tracks writes per superstep and stops when the graph stabilizes — nodes fire but produce no new writes — rather than only at the superstep cap.
  • Termination channels. A configurable channel set (default shouldContinue) is checked after each superstep; a false signal ends the run early.
  • Per-node retry. A node's SuperstepNode.retryPolicy overrides the global policy, with exponential backoff between attempts (baseDelayMs × backoffMultiplier^attempt). A node that exhausts its attempts logs a [SuperstepRunner] Node FAILED breadcrumb naming its subscriptions and declared writes — the failure is attributable, not silent.
  • Write-filtered skipping. A node whose subscribed channels produced no meaningful writes doesn't fire; an empty-object return from a node doesn't bump channel versions, so a no-op can't retrigger its subscribers.

These are properties of the TypeScript reference engine. The multi-language wire contract specifies the shapes on the wire, not the scheduler — an independent runtime may converge differently.

The default agent graph

buildDefaultAgentGraph(config) returns the pre-wired topology that covers the four-stage lattice end to end: intent detection, planning, the tool-loop, synthesis, and post-turn cleanup. The matching state shape is DefaultAgentState; the frozen per-turn flag baseline is DEFAULT_TURN_FLAGS. Pass the factory's return value to SessionRuntime and you get a working agent without authoring the graph yourself.

The factory's contract is dependency injection. You bring the executors — LlmExecutor for seam-bound LLM calls, ToolExecutor for tool dispatch, SubagentExecutor for spawned sub-runtimes, and the enrichment hooks for plan generation, intent detection, and quality scoring. The factory wires each executor into the right node with the right seam. Omit an executor and the node short-circuits to its default pass-through — the graph still runs, that stage's enrichment is just absent.

The post-tool tier nodes (enrichment, safetyReview, quality, citation) are agnostic-by-injection. The node bodies are domain-free; host runtimes supply the domain logic through config.{enrichmentExecutor, safetyReviewExecutor, qualityEvaluator, citationExtractor}. The factory only registers the node when the matching executor is provided, so absent executors mean absent nodes — pure dependency inversion, no hardcoded host logic in the graph layer.

import { buildDefaultAgentGraph } from "@pleach/core/graph/defaultAgentGraph"
import type { DefaultAgentState } from "@pleach/core/graph/defaultAgentState"

const graph = buildDefaultAgentGraph({
  llmExecutor,
  toolExecutor,
  subagentExecutor,
  // intentDetector, planGenerator, qualityScorer all optional
})

The node-level shape — what each node's metadata declares, how the seam binding works, what an authored node looks like — is at Nodes.

The lattice gate

Every node declares stageId: "anchor-plan" | "tool-loop" | "synthesize" | "post-turn" in its metadata. npm run audit:graph-stages parses the default agent graph at CI time and fails on out-of-lattice edges. The lattice is what lets per-stage cost rollup, observability slicing, and time-travel be structural rather than convention — a node that fires without a stage can't ship.

See Architecture § stage lattice for the four legal cross-stage transitions and the SELECT stage_id, SUM(token_cost) FROM harness_auditable_calls rollup shape that depends on the gate. See Audit gates for the script's invocation and the rest of the pre-merge gate set.

Structural pins

The lattice (D-36) is one-way; the only backward edge is the bounded synthesize → tool-loop re-entry (at most once per turn, same messageId — a post-synthesis validator that finds unaddressed planned work sets needsMoreToolWork, which the tool-loop entry node consumes):

anchor-plan → tool-loop → synthesize → post-turn → (next turn) anchor-plan
                  ↘ post-turn   (recovery dispatch)
                  ↖ synthesize  (bounded re-entry, ≤1/turn)

ALLOWED_EDGE_PATTERNS enumerates nine legal (from-stage, to-stage) pairs: six cross-stage transitions (anchor-plan → tool-loop, tool-loop → synthesize, synthesize → post-turn, the tool-loop → post-turn recovery-dispatch edge, the bounded synthesize → tool-loop re-entry, and the post-turn → anchor-plan rollover) and three intra-stage chains (anchor-plan, tool-loop, post-turn — the synthesize → synthesize retry self-loop is retired and now forbidden). The remaining seven pairs of the 4×4 cross-product sit in FORBIDDEN_EDGE_PATTERNS; the audit reports the exact violation pattern.

The NODE_STAGE_MAP registry in topology.ts carries 42 names — the upper bound on what a canonical graph can register. A real compile under a given buildDefaultAgentGraph(config) wires the subset whose executor or hook the host supplied; an absent executor means an absent node. npm run audit:graph-stages asserts the compiled node and edge counts are byte-identical pre- and post-change for the same config — a structural pin that catches both silent node additions and edge drift. New nodes ship with a paired NODE_STAGE_MAP entry in topology.ts and a documented edge-count ratchet; the audit is the regression-detection surface. The two reference catalogs — Node catalog and Edge catalog — enumerate the 42-name registry and the nine-pattern allowed edge table the gate enforces.

The forbidden set is enumerated, not implicit. Skipping the tool-loop (anchor-plan → synthesize, anchor-plan → post-turn), re-planning mid-turn (tool-loop → anchor-plan), post-synthesis re-planning (synthesize → anchor-plan), the retired synthesize → synthesize self-loop, and post-turn re-entering an active turn (post-turn → tool-loop, post-turn → synthesize) all fail at CI. Two pairs that look forbidden aren't: tool-loop → post-turn is the recovery-dispatch edge, and synthesize → tool-loop is the bounded re-entry — both sit in the allowed table above.

Subscription-derived transitions (G-14)

The edge table is not the only routing surface. The engine schedules nodes by subscription: a node whose subscribes list includes a channel written by a later-stage node is scheduled by that write, producing a runtime stage transition that never appears as an addEdge call. audit:graph-stages rule G-14 closes that gap — it derives the writer → subscriber stage pairs statically from each node's declared subscribes/writes and validates them against the same allowed-pattern table the edges answer to.

Deliberate backward subscriptions are exempted in ACCEPTED_SUBSCRIPTION_TRANSITIONS in topology.ts — one entry per (subscriber, channel), scoped to a named writer or writer stage, each carrying a written rationale. Every entry must match at least one live derived transition; the gate fails on a stale entry, so a retired subscription can't leave a permanent hole in the lattice. A novel backward subscription therefore fails CI instead of hiding behind a blanket allowance.

acceptsSeam reservation

Every node declares acceptsSeam: CallClass | null in its metadata. The literal is the seam the node reserves — the utility, reasoning, converse, or synthesize call class the node consumes at invocation time. null is for pure state transforms: anchor builders, context projectors, deterministic reducers, post-stream detectors.

The reservation is what lets future LLM-bearing growth attach without re-typing the lattice. A pure transform that later wants to call a model flips its acceptsSeam: null to a CallClass literal, and the seam binding routes through the existing holder + cap machinery — no edge surgery, no audit drift.

Singleton synthesize seam

Exactly one ProviderSeam<"synthesize"> per SessionRuntime, served by SynthesizeSeamHolder (D-38, D-50). The holder is per-runtime, not module-global — a single Node.js process runs many SessionRuntime instances concurrently, and module-global state would either throw on a second runtime's init or clobber the first runtime's adapter/counter binding and mis-attribute audit rows.

createSynthesizerNode — and the recovery path that stands in for it — consume the synthesize seam identity through the holder. The tool-loop's createLlmDecisionNode is utility-class and consumes a separate seam, so the singleton is per call class (D-72): only the synthesizer reaches the synthesize seam. The cap (TurnSynthesizeCounter) is idempotent on messageId (D-37) — the synthesize-self-loop in the lattice is guarded by message-id equality so a retry produces one row, not two.

The wire-check enforces both invariants from the test layer:

npm run test:graphnoderef-wire-check

It compiles the default graph, asserts the holder is initialized exactly once per runtime, and asserts the synthesizer + the decision node observe the same ProviderSeam<"synthesize"> reference.

Determinism

The graph engine is reactive but deterministic. Same channel versions feeding the same superstep produce the same firing order; two nodes that race on a channel resolve through the channel's reducer, which is required to be commutative and associative. Stream observers are sync-only — no Promise<Verdict> overload — so replay matches record on the first observation.

Annotations expose checkpoint() and restore() per channel, so a session can rewind to any prior superstep and re-fire the graph from that point. See Determinism for the full property set and Checkpointing for the per-channel snapshot contract.

Plugins and extra nodes

HarnessPlugin.extraGraphNodes() returns PluginGraphNodeRegistration[] — { name, factory, metadata? } entries. The graph builder calls each factory once at compile time and adds the returned node to the topology under the plugin's namespace. Domain-specific nodes (a job-silo dispatcher, a sandbox workspace index, a custom enrichment pass) register here rather than living in the substrate's generic builder.

The plugin can't add an out-of-lattice edge or bypass the singleton synthesize seam — both fail audit:graph-stages at CI before the registration takes effect. See Plugin contract for the full extension surface.

Tuning shadow (experimental)

@pleach/core/graph/tuning ships shadow-mode tuning over five tool-loop constants — the knobs in the TuningKnobId union: loopTerminatedPostExhaustLimit, delegationMinRemainingSteps, delegationMinDistinctTools, substantiveAnswerMinChars, and answerSufficiencyMinConfidentDraftLen.

The loop has three parts:

  1. Derive. From event-ledger observations, buildTuningShadowSnapshot derives one proposed value per knob — the nearest-rank quantile of the observed distribution, clamped to a pre-approved envelope. The per-knob policy is a KnobDerivationPolicy: minObservations, quantile, clamp, roundTo. Derivation is pure — no DB, clock, or graph-state reads; observations arrive as plain numbers.
  2. Install. The derived TuningShadowSnapshot installs into a process-wide holder — installTuningShadowSnapshot to set, readTuningShadowSnapshot to read. installTuningShadowSnapshotFromEnv reads the TUNING_SHADOW_SNAPSHOT env var as the path to a JSON snapshot file (Node-only; a bad path or malformed file warns and installs nothing).
  3. Compare. At each knob's live read site, one [tuning:shadow] knob-delta telemetry line compares the decision the live constant just made against the decision the derived value would have made. diverged: true is the line worth grepping.

The design contract: routing is never touched. A derived value never changes a decision — promotion to a live constant is a human edit at the constant's declaration. A knob whose observations are shaped by its own gating carries selfInfluenced: true on every verdict, so a consumer can refuse to auto-promote it. Everything is fail-open — a malformed snapshot costs a telemetry line, never a turn.

Two reading rules:

  • A capture with zero [tuning:shadow] lines means no snapshot was installed — never "no divergence".
  • Graph read sites import the holder and emit modules directly, never the barrel. The barrel re-exports live constants from graph modules (knobCurrents), so a node or predicate importing it would create an import cycle. The barrel is for scripts, tests, and hosts.

Where to go next