pleach
Architecture

Graph

The declarative topology that drives a turn — Annotation channel schema, StateGraph builder, Send fan-out, 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,
  START,
  END,
  CompiledGraph,
  Annotation,
  AnnotationRoot,
  Send,
  isSend,
  DefaultAgentState,
  buildDefaultAgentGraph,
  DEFAULT_TURN_FLAGS,
} from "@pleach/core/graph"

import type {
  StreamEvent,
  StateGraphNodeMetadata,
  AnnotationSchema,
  InferAnnotationState,
} from "@pleach/core/graph"

Three pieces

Annotation — channel schema

Annotation<T> declares one channel's default and reducer. AnnotationRoot collects them into a typed schema. The engine reads the schema at compile time to instantiate the underlying Channel<T> instances — the same six kinds documented at Channels.

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

const StateSchema = AnnotationRoot({
  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 inputs: ["messages"] in its metadata sees State narrowed to { messages: Message[] } at the call site, and writing to a channel not in the schema is a compile error.

StateGraph — declarative builder

StateGraph is the builder. .addNode(name, fn, metadata) registers a node; .addEdge(from, to) wires a static edge; .addConditionalEdges(from, routeFn, edgeMap) wires a branching edge; .compile() returns a CompiledGraph.

const graph = new StateGraph(StateSchema)
  .addNode("agent", agentNode, {
    stageId: "tool-loop",
    acceptsSeam: "reasoning",
    inputs: ["messages"],
    outputs: ["messages"],
  })
  .addNode("tools", toolsNode, {
    stageId: "tool-loop",
    acceptsSeam: null,
    inputs: ["messages"],
    outputs: ["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 — conditional fan-out

Send is the return value from a conditional-edge function that dispatches the same target node multiple times with different state slices. It's the fan-out primitive: tool-batch execution, parallel subagent spawn, map-reduce over a doc set.

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

function dispatchTools(state: State): Send[] {
  const pending = state.messages.at(-1)?.tool_calls ?? []
  return pending.map((call) => new Send("tool_runner", { call }))
}

graph.addConditionalEdges("agent", dispatchTools)

The engine schedules each Send as a concurrent invocation of the target node with its own state slice. Writes land through the channel reducers — messagesReducer for the message accumulator, appendReducer for a Topic, and so on — so fan-out is deterministic when the reducers are commutative.

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

Three 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, edgeMap?)The route function's return value
Fan-out.addConditionalEdges(from, routeFn) returning Send[]One target invocation per Send

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 | Send | Send[]. When it returns a string, the optional edgeMap translates the label to a node name — 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",
  )
  .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, and the four intra-stage chains — anchor-plan, tool-loop, post-turn, plus the synthesize → synthesize retry self-loop. 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).

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, DefaultAgentState } from "@pleach/core/graph"

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 messageId-guarded synthesize → synthesize retry:

anchor-plan → tool-loop → synthesize → post-turn → (next turn) anchor-plan
                  ↘ post-turn   (recovery dispatch)

ALLOWED_EDGE_PATTERNS enumerates nine legal (from-stage, to-stage) pairs: five cross-stage transitions (anchor-plan → tool-loop, tool-loop → synthesize, synthesize → post-turn, the tool-loop → post-turn recovery-dispatch edge, and the post-turn → anchor-plan rollover) and four intra-stage chains (anchor-plan, tool-loop, post-turn, plus the synthesize → synthesize retry). 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 44 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 44-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 tool dispatch or re-planning (synthesize → tool-loop, synthesize → anchor-plan), and post-turn re-entering an active turn (post-turn → tool-loop, post-turn → synthesize) all fail at CI. tool-loop → post-turn is not forbidden — it's the recovery-dispatch edge in the allowed table.

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.

Where to go next

On this page