Documentation
¶
Overview ¶
Package plan is rung-1 of the expression-surfaces stack: a handle-based flow builder that lowers to the journal-backed runtime and adds no new executor.
A caller describes a flow by naming durable steps and wiring them with typed handles. Each Builder.Step, Builder.Tool, and Builder.Model returns a Handle[I, O] that satisfies Producer[O] and Consumer[I] through unexported marker methods, so Builder.Edge and Builder.Switch unify the connecting type at compile time and only handles can be endpoints. The wiring is reified into an internal spec (nodes, edges, branches) rather than executed eagerly.
Builder.Build lowers that spec onto the existing durable runtime: every node becomes an agent.Step keyed by its journal name, and each Switch choice becomes its own journaled step recording the taken arm. Flow.Run drives the lowered flow sequentially with no goroutines or errgroup, so it inherits the substrate's at-most-once, halt, and resume guarantees unchanged. The builder is a surface over agent.Step control flow; it introduces no separate scheduler or executor.
Builder.Join2 and Builder.Join3 add fixed-arity fan-in: a node that consumes two (or three) upstream producers that BOTH execute and merges them, so a fan-out (a producer with several outgoing edges) can reconverge. Flow.Run executes the reachable DAG in a deterministic topological order, strictly sequentially: a Join runs only after all its inputs are journaled, so the fan-in barrier is realized by ordering, not concurrency (still no goroutines or errgroup). A Switch still prunes to its taken arm; Build rejects a Join whose inputs a Switch can skip (Join is fan-in of both-execute branches, not reconvergence of mutually-exclusive arms). A Join is expressible both through the Go builder and in a declarative config: the config loader (Load) carries a "join" wiring element ({"join":name,"inputs":[...], "merge":block}) whose merge block is registered with RegisterJoin2/RegisterJoin3.
LoopBack adds a BOUNDED loop: a Switch arm whose target is an earlier loop head is a back-edge, and the arm carries a max-iteration bound. The other arm(s) of the same Switch are the exit; Build requires one so the loop can terminate. The back-edge is excluded from the forward (acyclic) graph, so Kahn still linearizes it and every other check is unchanged; Flow.Run re-enters the head per iteration under ITERATION-SCOPED journal keys (iter:<n>:<node>), so at-most-once, halt-on-ambiguity, and resume hold per iteration exactly as for a linear flow, and a run that would exceed the bound errors rather than spinning forever. The loop is still driven by a plain sequential Go for-loop: no goroutine, channel, or scheduler. The type frontier (the value routed on the back-edge equals the head's input type) is enforced at the LoopBack call site by the compiler. Digest commits to the loop STRUCTURE (head, switch, body region, and max bound) but never the runtime iteration count; conform strips the iteration prefix to map each key back to its declared node. Like Join, a bounded loop is expressible both through the Go builder and in a declarative config: the config loader (Load) carries the back-edge as a Switch "when" arm with a positive "loopMax" ({"pred":p,"to":head,"loopMax":n}), which Build lowers exactly as LoopBack.
Because the topology is declared as a value, plan can render the authored graph (Flow.RenderMermaid) and compare it against the journal-derived path of a run (Flow.Conform), distinguishing the declared diagram from the diagram agent.RenderMermaid recovers from the journal.
See docs/design/expression-surfaces.md for the design and the open points carried by this rung (arm reconvergence and the no-new-executor invariant).
Index ¶
- func RegisterJoin2[A, B, O any](r *Registry, name string, fn func(A, B) (O, error)) error
- func RegisterJoin3[A, B, C, O any](r *Registry, name string, fn func(A, B, C) (O, error)) error
- func RegisterModel[I, O any](r *Registry, name, prompt string, opts ...NodeOption) error
- func RegisterPredicate[M any](r *Registry, name string, pred func(M) bool) error
- func RegisterStep[I, O any](r *Registry, name string, fn func(I) (O, error), opts ...NodeOption) error
- func RegisterTool[I, O any](r *Registry, name string, t agent.Tool, opts ...NodeOption) error
- func Validate(data []byte, reg *Registry) error
- type Arm
- type Builder
- func (b *Builder[In, Out]) Build() (*Flow[In, Out], error)
- func (b *Builder[In, Out]) Edge[M any](from Producer[M], to Consumer[M])
- func (b *Builder[In, Out]) Join2[A, B, O any](name string, a Producer[A], bb Producer[B], fn func(A, B) (O, error), ...) Handle[O, O]
- func (b *Builder[In, Out]) Join3[A, B, C, O any](name string, a Producer[A], bb Producer[B], cc Producer[C], ...) Handle[O, O]
- func (b *Builder[In, Out]) Model[I, O any](name, prompt string, opts ...NodeOption) Handle[I, O]
- func (b *Builder[In, Out]) Step[I, O any](name string, fn func(I) (O, error), opts ...NodeOption) Handle[I, O]
- func (b *Builder[In, Out]) Switch[M any](over Producer[M], arms ...Arm[M])
- func (b *Builder[In, Out]) Tool[I, O any](name string, t agent.Tool, opts ...NodeOption) Handle[I, O]
- func (b *Builder[In, Out]) WithModel(m agent.Model) *Builder[In, Out]
- type Consumer
- type Flow
- func (f *Flow[In, Out]) Conform(ctx context.Context, store agent.Durable, runID string) (ok bool, diffs []string, err error)
- func (f *Flow[In, Out]) Digest() string
- func (f *Flow[In, Out]) RenderMermaid() string
- func (f *Flow[In, Out]) Run(ctx context.Context, store agent.Durable, runID string, in In) (Out, error)
- func (f *Flow[In, Out]) Topology() Topology
- type HaltAmbiguous
- type Handle
- type LoadOption
- type NodeOption
- type Producer
- type Registry
- type Topology
- type TopologyBranch
- type TopologyBranchArm
- type TopologyEdge
- type TopologyJoin
- type TopologyLoop
- type TopologyNode
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func RegisterJoin2 ¶
RegisterJoin2 registers a fixed-arity fan-in merge func(A, B) (O, error) as a merge block named name, inferring A, B, and O from fn (the config never restates types; they flow from the registered merge). It is the config counterpart of Builder.Join2: a "join" wiring element references the merge block by name, names its two ordered inputs, and assemble builds a kindJoin node whose ordered input types are A, B and whose type-erased merge closure asserts each boxed input to its concrete type before calling fn, exactly like Builder.Join2. A duplicate name is an error, surfaced at Load and returned here for inline checking.
Safety is not carried on a merge block: like a hand-built Join a loaded join defaults to the conservative halt-on-ambiguous-crash, and an explicit config "safety" on the join node overrides it (see the join node's safety field).
func RegisterJoin3 ¶
RegisterJoin3 is RegisterJoin2 for arity three: it registers a fan-in merge func(A, B, C) (O, error) as a merge block named name, inferring A, B, C, and O from fn. It is the config counterpart of Builder.Join3: a "join" element names three ordered inputs and assemble builds a kindJoin node whose ordered input types are A, B, C and whose erased merge closure asserts each boxed input before calling fn. A duplicate name is an error, surfaced at Load and returned here for inline checking.
func RegisterModel ¶
func RegisterModel[I, O any](r *Registry, name, prompt string, opts ...NodeOption) error
RegisterModel registers a model call built from prompt as a Model block named name. I and O are explicit because a prompt string infers nothing: I is the input rendered into the prompt and O is the structured result. It mirrors Builder.Model: at run time the loaded node renders prompt as a Go text/template with the decoded input I as data, calls the model bound to the loaded flow, and decodes the model's text response as JSON into O. A duplicate name is an error, surfaced at Load and returned here for inline checking.
The model is bound to the loaded flow with WithLoadedModel at Load time (a Registry carries no model); a config that declares a Model block loaded without a bound model fails Build, naming the node. O must be a JSON-shaped type.
Pass plan.ReadOnly()/plan.Idempotent() to record the block's retry-on-resume Safety in Go (the config JSON carries no Safety), mirroring Builder.Model; the default keeps the conservative halt-on-ambiguous-crash for a non-idempotent model call.
func RegisterPredicate ¶
RegisterPredicate registers a Switch predicate func(M)bool named name, capturing M via reflect.TypeFor[M] so Load can verify M equals the switched node's output type. The stored predicate is erased to func(any)bool with a v.(M) assertion exactly like wiring.go's Switch: a wrong dynamic type means the arm did not match. A duplicate name is an error, surfaced at Load and returned here for inline checking.
func RegisterStep ¶
func RegisterStep[I, O any](r *Registry, name string, fn func(I) (O, error), opts ...NodeOption) error
RegisterStep registers an arbitrary func(I)(O,error) as a Step block named name, inferring I and O from fn (the config never restates types; they flow from the registered block). The installed run closure mirrors Builder.Step: it asserts the erased input is I, calls fn, and boxes the O result back as any. A duplicate name is an error, surfaced at Load and returned here for inline checking.
Pass plan.ReadOnly()/plan.Idempotent() to record the block's retry-on-resume Safety in Go (the config JSON carries no Safety); a loaded node then resumes identically to one built with Builder.Step and the same option.
func RegisterTool ¶
RegisterTool registers an agent.Tool as a Tool block named name. I and O are explicit because an agent.Tool is untyped (json.RawMessage in and out): the I input is JSON-encoded into the tool's args and the tool's JSON result is decoded into O, exactly like Builder.Tool. A duplicate name is an error, surfaced at Load and returned here for inline checking.
Safety AUTO-DERIVES from t.Safety(), mirroring Builder.Tool; an explicit plan.ReadOnly()/plan.Idempotent() option overrides the derived Safety. Safety is recorded in Go here, not in the config JSON.
func Validate ¶
Validate parses and checks a config against reg without producing a Flow, so config-vs-registry drift is catchable in a unit test or CI rather than only at process start. It runs every check Load runs that does not require the flow's In/Out type parameters: the discriminated-union structural check, block and predicate resolution (reporting all unknowns at once with near-miss suggestions and flagging unused blocks), the switched-vs-edged exclusivity check, and the intra-graph type checks (every edge's from.outType == to.inType, every switch arm's predicate M == the switched node's output type).
The only checks Validate cannot run are the ones that need In/Out: entry consumes In, every terminal produces Out, and the optional in/out documentation cross-check. Those run at Load, where the boundary types are known. Validate is therefore the drift guard (the stringly-typed footgun re-created at rung 2); Load is the full typed check.
Types ¶
type Arm ¶
type Arm[M any] struct { // contains filtered or unexported fields }
Arm is one branch of a Switch, produced by When, Else, or LoopBack. It carries the typed predicate over the switched node's output (nil for an Else arm) and the target step's name. Switch type-erases the predicate to func(any)bool at the wiring boundary.
A LoopBack arm sets loopBack and loopMax: its target is an EARLIER loop head, so the arm is a bounded back-edge (a cycle) rather than a forward route. See LoopBack.
func Else ¶
Else routes to `to` when no When arm matched. At most one Else per Switch, which Build validates. An Else arm carries no predicate.
func LoopBack ¶
LoopBack declares a BOUNDED LOOP as a Switch arm: when pred(over.Out) is true, route the switched value BACK to `head` (an earlier node, the loop head) and run the loop body again, up to max iterations. It is the rung-1 way to express a bounded loop with NO new executor: a loop is a back-edge (a Switch arm whose target is an ancestor of the Switch) plus an iteration bound. The other arm(s) of the same Switch are the exit; Build requires at least one non-loop-back arm so the loop can terminate.
The type frontier is enforced by the compiler, not at run time: `head` is a Consumer[M] and the Switch unifies M with over.Out, so the value routed on the back-edge (of type M) must equal the head's input type. A single-type loop is the only shape LoopBack expresses, by construction.
max must be > 0. Run journals each iteration's node executions under iteration-scoped keys (iter:<n>:<node>), so at-most-once, halt-on-ambiguity, and resume all hold PER ITERATION exactly as for a linear flow; if the loop would re-enter the head more than max times without taking the exit arm, Run returns a runaway-loop error rather than looping forever. Like When, pred must be pure over the switched value: each iteration's choice is journaled and replayed on resume.
LoopBack co-locates the back-edge, its predicate, its target head, and the bound in one typed call, which reads more cleanly than a separate Loop(head, max) marker and keeps the type-frontier check at the call site where the compiler can enforce it.
type Builder ¶
type Builder[In, Out any] struct { // contains filtered or unexported fields }
Builder[In, Out] accumulates a flow spec. In and Out are the pinned flow boundary types; the generic-method constructors (Step, Tool, Model) and wiring calls (Edge, Switch) mutate the wrapped *builderCore. Construction is pure: it records the topology and installs type-erased lowering closures, and runs no step. Build freezes the spec into a *Flow.
func New ¶
New constructs a flow builder whose input is In and output is Out, both pinned here so the flow boundary is checked at construction. name is the flow's label (used by Flow.RenderMermaid and conformance). Build later validates that the entry step consumes In and every terminal path produces Out.
The first constructor call on the returned Builder (Step, Tool, or Model) sets the entry step; New itself records no node.
func (*Builder[In, Out]) Build ¶
Build validates the accumulated spec for whole-graph coherence and freezes it into a *Flow[In, Out]. It performs no I/O and runs no step: it is a pure static check over the reified topology.
The validations, in order, and each error names the offending step:
- construction errors recorded during authoring (duplicate names) surface first;
- the entry step consumes In (entry.inType == the pinned In);
- every node is reachable from the entry via edges and Switch arms;
- every terminal path produces Out (a node with no outgoing edge and that is not switched over must have outType == the pinned Out);
- each Switch has at most one Else and a predicate typed to the switched output.
On success it returns a *Flow whose spec is sealed: the returned value holds a distinct *builderCore, so mutating the original Builder after Build cannot change the frozen flow.
func (*Builder[In, Out]) Edge ¶
Edge connects a producer to a consumer, unifying the connecting type M at the call site: the producer's output type and the consumer's input type must both be M, so a mismatch does not compile and the compiler error names the handles, not an erased spec entry. Edge records the endpoint names only; the values flow through the journal at run time.
func (*Builder[In, Out]) Join2 ¶
func (b *Builder[In, Out]) Join2[A, B, O any](name string, a Producer[A], bb Producer[B], fn func(A, B) (O, error), opts ...NodeOption) Handle[O, O]
Join2 registers a fixed-arity fan-in named name that consumes two upstream producers a and b and merges them with fn. It is the rung-1 way to reconverge two branches that BOTH execute (for example a fan-out X -> {Y, Z} whose results Join2(Y, Z) merges), as opposed to a Switch, whose arms are mutually exclusive.
The input types are unified at the call site through the generic parameters, exactly as Edge unifies M: a is a Producer[A] and b a Producer[B], so the merge fn func(A, B) (O, error) receives the two producers' outputs in order and a mismatch does not compile. Join2 wires one input edge per producer (a -> name, b -> name) and records the ordered input source names and their reflect types, so Build can re-check the port types by reflect identity and Run can decode each journaled input into its concrete Go type before calling fn.
The returned Handle names the join and produces O, so it composes downstream (Edge from it, Switch over it, or as an input to another Join). Its input type parameter is O and carries no meaning: a join has several inputs, wired through its arguments rather than through a single Edge, so nothing consumes the handle's Consumer side.
SAFETY: like a Step or Model, a Join defaults to the conservative halt-on-ambiguous-crash (its merge fn may have a side effect). Because a join runs only AFTER all its inputs are journaled (Run executes the reachable DAG in topological order), the merge is a plain sequential step under the same attempt/result guard as every other node; the fan-in is a topological barrier, not concurrency. Pass plan.ReadOnly()/plan.Idempotent() (or plan.Retryable()) to opt the join into re-run on an ambiguous crash instead of halting.
func (*Builder[In, Out]) Join3 ¶
func (b *Builder[In, Out]) Join3[A, B, C, O any](name string, a Producer[A], bb Producer[B], cc Producer[C], fn func(A, B, C) (O, error), opts ...NodeOption) Handle[O, O]
Join3 is Join2 for arity three: it consumes three upstream producers a, b, c and merges them with fn func(A, B, C) (O, error). Everything else matches Join2: the input types unify at the call site, it wires one input edge per producer in order, records the ordered input source names and their reflect types for Build to re-check, defaults to halt-on-ambiguous-crash, and returns a Handle producing O.
func (*Builder[In, Out]) Model ¶
func (b *Builder[In, Out]) Model[I, O any](name, prompt string, opts ...NodeOption) Handle[I, O]
Model registers a model call built from prompt as a durable step named name. name must be unique across the flow; a duplicate is recorded as a deferred error surfaced at Build. I and O are explicit because a prompt string infers nothing: I is the input rendered into the prompt and O is the structured result.
At run time the node (see runModel): renders prompt as a Go text/template with the decoded input I as its data (so {{.Field}} references the input's fields), sends the rendered text as a single user message to the model bound with WithModel under a JSON-schema response format derived from O, then decodes the model's text response into O. The model must therefore be bound with Builder.WithModel before Build; a flow declaring a Model step with no bound model fails Build, naming the node. O must be a JSON-shaped type (a struct is the usual structured-output shape), because the model's response is decoded as JSON into O.
SAFETY: a model call is non-idempotent by default (it may cost tokens and its output can vary between calls), so a Model node keeps the conservative halt-on-ambiguous-crash default: on a mid-node crash whose result was lost, Run HALTS rather than re-call the model. Pass plan.ReadOnly() or plan.Idempotent() (or the plan.Retryable() alias) only if you know a re-call is safe; that opts the node into re-run on the ambiguous crash instead of halting.
func (*Builder[In, Out]) Step ¶
func (b *Builder[In, Out]) Step[I, O any](name string, fn func(I) (O, error), opts ...NodeOption) Handle[I, O]
Step registers an arbitrary func(I)(O,error) as a durable step named name. name must be unique across the flow; a duplicate is recorded as a deferred error surfaced at Build. Step infers I and O from fn.
The func body is the escape hatch: arbitrary Go. Run drives every Step under an at-most-once guard (an attempt marker written before the body, the result after), so a crash whose outcome was never recorded HALTS the run (*HaltAmbiguous) rather than re-firing the body. A non-idempotent side effect is therefore safe by default, with no per-step opt-in. See docs/design/expression-surfaces.md.
Pass plan.ReadOnly() or plan.Idempotent() (or the plan.Retryable() alias) to opt a node OUT of that halt: a retry-safe node RE-RUNS its body from the top on an ambiguous mid-node crash instead of halting. Omit the option to keep the conservative halt behavior unchanged.
A Step is non-retry-safe by DEFAULT, so this applies even to a step with no side effect: a pure-compute or read-only step still HALTS on an ambiguous crash unless annotated. Mark such steps plan.ReadOnly() (or plan.Idempotent()) so they re-run on resume instead of stalling the flow; reserve the default halt for steps whose body must not repeat.
func (*Builder[In, Out]) Switch ¶
Switch routes on over.Out to exactly one arm. M unifies the switched producer's output with every arm's predicate/target input, so an arm typed to the wrong value does not compile. The branch choice is journaled as its own agent.Step at Build/Run time (Agent D), so a resumed run replays the recorded arm and only the taken arm executes. Arms do not reconverge in rung-1 (open design point a): each arm terminates a path that Build checks produces Out.
func (*Builder[In, Out]) Tool ¶
func (b *Builder[In, Out]) Tool[I, O any](name string, t agent.Tool, opts ...NodeOption) Handle[I, O]
Tool registers an agent.Tool as a durable step named name. name must be unique across the flow; a duplicate is recorded as a deferred error surfaced at Build. Tool infers I and O: the I input is JSON-encoded into the tool's args and the tool's JSON result is decoded into O.
Safety AUTO-DERIVES from the wrapped agent.Tool: Tool records t.Safety() on the node, so a tool the core classifies as retry-safe (ReadOnly, Idempotent, or carrying an IdempotencyKey) RE-RUNS on an ambiguous mid-node crash while a non-idempotent tool HALTS, matching the core loop's own resume decision. An explicit plan.ReadOnly()/plan.Idempotent() option OVERRIDES the derived Safety (options apply after the literal), for the rare case the author knows better than the tool's own declaration.
func (*Builder[In, Out]) WithModel ¶
WithModel binds an agent.Model to the flow, so a Model step can render its prompt, call the model, and decode the structured result. The binding is read at RUN time by every Model node (a Model node does not know the model at construction), so one bound model serves the whole flow. A later WithModel overrides an earlier one (last write wins). Build rejects a flow that declares a Model step with no bound model, naming the node. Returns the builder for chaining: New[I,O](name).WithModel(m). It binds identically to a flow produced by Load, so a config-loaded flow with a Model node runs once a model is bound.
type Consumer ¶
type Consumer[M any] interface { // contains filtered or unexported methods }
Consumer[M] is the input side of a wired step: it accepts a value of type M. Handle[I,O] satisfies Consumer[I]. The marker method is unexported (a phantom method) so only handles can be consumers; it carries no runtime behavior.
type Flow ¶
type Flow[In, Out any] struct { // contains filtered or unexported fields }
Flow[In, Out] is a built, frozen flow: the reified topology validated by Build, ready to run durably. In and Out are the pinned flow boundary types. Its spec is sealed at Build time, so a Flow value is immutable and safe to run repeatedly (each Run keyed by a distinct runID). Flow adds no executor: Run lowers the topology onto the journal-backed runtime via agent.Step.
func Load ¶
Load parses a rung-2 JSON config, resolves every block and predicate against reg, validates the whole topology at load time, and returns the built *Flow[In, Out]. It is where compile-time typing hands off to load-time validation: In and Out are supplied at the Go call site (the caller knows the boundary types) and the loader fills the middle from data.
Load reports ALL resolution failures at once, naming every unresolved block or predicate (with a near-miss suggestion where one exists) and flagging registered blocks the config never uses. It then runs the load-time type checks by reflect.Type identity: the entry consumes In, every terminal produces Out, every edge's from.outType equals to.inType, and every switch arm's predicate M equals the switched node's output type (the improvement over rung 1, which only checks a predicate at its compile-time call site). It also enforces two structural checks Build does not: a node cannot be both switched-over and have an outgoing edge, and a wiring element must set exactly one of edge/switch. On success it hands the assembled spec to the existing Build (whole-graph validation) and seal.
func LoadReader ¶
func LoadReader[In, Out any](r io.Reader, reg *Registry, opts ...LoadOption) (*Flow[In, Out], error)
LoadReader is Load reading the config from r (for example an *os.File or an HTTP body). It reads r to EOF, then behaves exactly like Load.
func (*Flow[In, Out]) Conform ¶
func (f *Flow[In, Out]) Conform(ctx context.Context, store agent.Durable, runID string) (ok bool, diffs []string, err error)
Conform checks the journaled path for runID against f's declared topology. ok is true iff every journaled step name maps to a declared node (or is a recognised internal marker of one) and every journaled Switch choice picked a declared arm of that Switch. diffs lists divergences by name (an unexpected step, or an unreachable arm taken); when ok is true diffs is empty.
This is the accountability property of docs/design/expression-surfaces.md: prove the run followed the declared graph, or point at where it diverged. Conform reads history via agent.Durable.History and compares it to the frozen spec; it runs nothing, adds no executor, and never mutates the store.
Conform is DECLARATION-vs-JOURNAL, not value verification. Its blind spot: it sees THAT a declared Step ran and which arm a Switch took, but not what the arbitrary Go inside a Step body actually did. A Step that runs but computes the wrong value still conforms; conformance is about topology and routing, not about the correctness of a node's computation.
Conform forwards to the internal (*builderCore).conform so a single method owns the logic against the frozen spec; the exported method only unwraps the Flow.
func (*Flow[In, Out]) Digest ¶
Digest returns a deterministic SHA-256 hash (hex-encoded) of this frozen flow's DECLARED topology: the flow name, every node's name+kind+input type+output type, every edge, and every Switch and its ordered arms. It is a stable fingerprint of the diagram, computed by canonicalizing the spec in insertion order (never by map iteration), so two builds of the same topology produce the same digest and any change to the topology (a renamed or retyped node, an added or reordered edge, a changed arm) produces a different digest.
Run journals this digest under the reserved step name flow:digest, so the audit layer's Merkle tree and signed tree head cover it. That makes it offline-verifiable that a run followed THIS declared topology: an auditor proves the flow:digest record is included under a signed tree head and checks the proven digest equals the declared flow's Digest(). See docs/guides/flows.md, "Cryptographic conformance".
The digest commits to topology, not to node bodies: it does not hash the arbitrary Go inside a Step, only its declared shape (name and I/O types). This mirrors Conform, which is declaration-vs-journal, not value verification.
func (*Flow[In, Out]) RenderMermaid ¶
RenderMermaid returns a Mermaid flowchart of the DECLARED topology carried by this frozen Flow, each node labelled by its journal key and its I,O types. It forwards to the pure builderCore renderer (Agent C); it is the authored diagram, as opposed to agent.RenderMermaid, which derives the actual-ran diagram from the journal, and Conform compares the two.
func (*Flow[In, Out]) Run ¶
func (f *Flow[In, Out]) Run(ctx context.Context, store agent.Durable, runID string, in In) (Out, error)
Run executes the lowered flow durably for runID against store, resuming from the journal if steps already exist. In is fed to the entry step; the value the terminal step produces is decoded into Out and returned.
Run is STRICTLY SEQUENTIAL and adds no executor: it walks the declared topology one node at a time and drives each node as a named durable step through store.Do, exactly as plain Go control flow over agent.Step would (see docs/design/expression-surfaces.md, rung 0). It therefore inherits the substrate's guarantees unchanged: a completed step returns its recorded value without re-running (at-most-once by name), and a resumed run replays recorded steps rather than re-executing them.
Halt-on-ambiguity is AUTOMATIC and applies to every node, with no per-step opt-in (see runNode). Each node runs under the substrate's two-phase attempt/result protocol: Run records an attempt marker before invoking the node body, then records the result. On resume, a node whose attempt marker is present but whose result is missing crashed mid-effect with an unknown outcome, so Run HALTS (returns *HaltAmbiguous) rather than re-running the body. This is the plan surface inheriting the crown-jewel property of the substrate: a non-idempotent side effect fires at most once across a crash, for free, without the step author declaring any Safety.
A Switch is lowered the same way: Run reads the switched node's journaled output, evaluates the arm predicates ONCE, and records the chosen arm target as its own durable step named "switch:"+over. On resume the recorded choice is replayed rather than re-decided, so the predicates must be pure over the switched value (see When). Only the taken arm's downstream path executes.
Journal-record name scheme Run writes:
- "flow:digest" -- the frozen topology digest (StepValue whose Result is the JSON-encoded hex digest), recorded FIRST, before any node runs, and memoized on resume. It is an internal record of the run, not a declared node; conformance recognises it and verifies it equals the current flow's Digest() (see Conform).
then, per node named N:
- "attempt:"+N -- the attempt marker (StepValue, no Result), recorded before N runs;
- N -- N's result (StepValue whose Result is the JSON-encoded output);
- "switch:"+over -- for a switched node, the journaled arm choice (StepValue whose Result is the JSON-encoded chosen target step name).
The attempt marker "attempt:"+N is an internal step of node N, not a distinct declared node; conformance treats it as belonging to N.
func (*Flow[In, Out]) Topology ¶
Topology returns the stable, renderer-agnostic snapshot of this frozen flow's DECLARED topology as pure, JSON-serializable data. It is the structured model that Flow.RenderMermaid renders to Mermaid and that external tooling (a visual builder, an audit viewer) consumes directly, so callers are not coupled to the Mermaid string or to the opaque Digest. The returned value shares nothing with the frozen spec (it holds only copied strings and ints), so a caller may hold or mutate it freely, and it marshals through encoding/json without loss.
It reports the same topology the Digest commits to: the flow name and boundary types, the entry node, every node's name/kind/I-O types, every edge, every Switch and its ordered arms, every fan-in join with its ordered inputs, and every derived loop. It is a pure function of the sealed spec: no context, no store, no I/O, and deterministic because it walks the insertion-ordered nodes, edges, branches, and loops slices rather than any map.
type HaltAmbiguous ¶
HaltAmbiguous is returned by Run when a resumed node has a recorded attempt marker but no recorded result: the node's effect may have fired before the crash, so its outcome is unknown. Run stops rather than re-run the body and risk a double side effect, mirroring the core runtime's ResumeHalt. Step names the node that halted. Resolve it out of band (confirm whether the effect landed and record the result, or discard the run); Run does not decide that.
func (*HaltAmbiguous) Error ¶
func (e *HaltAmbiguous) Error() string
type Handle ¶
type Handle[I, O any] struct { // contains filtered or unexported fields }
Handle[I,O] is a compile-time name for a durable step consuming I and producing O. It satisfies Producer[O] and Consumer[I], so wiring calls unify the connecting type at compile time and only handles can be endpoints. It carries the step's journal key (name) and a back-reference to the owning builder's spec; the value it stands for lives in the journal at run time.
type LoadOption ¶
type LoadOption func(*builderCore)
LoadOption configures a flow at Load time (before Build). It is the loader's counterpart to the Builder's chained options: it mutates the assembled builderCore the loaded flow is built from. See WithLoadedModel.
func WithLoadedModel ¶
func WithLoadedModel(m agent.Model) LoadOption
WithLoadedModel binds an agent.Model to a flow produced by Load or LoadReader, so a config that declares a Model node (a RegisterModel block) can render its prompt, call the model, and decode the structured result at run time. It is the loader's equivalent of Builder.WithModel: pass it to Load and a loaded Model node resumes and runs identically to a hand-built one. A config with a Model node loaded without a bound model fails Build, naming the node.
type NodeOption ¶
type NodeOption func(*node)
NodeOption configures a node at construction (Step, Tool, Model) or at registration (RegisterStep, RegisterTool, RegisterModel). The only property it currently sets is the node's retry-on-resume Safety, mirroring agent.Safety for a core tool. Options apply in order; a later option overrides an earlier one, which is why an explicit ReadOnly/Idempotent on a Tool overrides the Safety auto-derived from the wrapped agent.Tool.
func Idempotent ¶
func Idempotent() NodeOption
Idempotent marks a node as idempotent: it mutates state but a repeat with the same input is a no-op downstream, so it is safe to retry. On an ambiguous mid-node crash Run RE-RUNS the body rather than halting. Use it for a node whose effect de-duplicates downstream (for example an upsert keyed by a stable id).
func ReadOnly ¶
func ReadOnly() NodeOption
ReadOnly marks a node as read-only: it has no external side effect, so it is always safe to re-run from the top on resume. On an ambiguous mid-node crash (an attempt marker with no result) Run RE-RUNS the body rather than halting. Use it for a node that only reads.
func Retryable ¶
func Retryable() NodeOption
Retryable is an alias for Idempotent, reading more naturally at some call sites (a node the author asserts is safe to retry on resume). It sets the same Safety.
type Producer ¶
type Producer[M any] interface { // contains filtered or unexported methods }
Producer[M] is the output side of a wired step: it yields a value of type M. Handle[I,O] satisfies Producer[O]. The marker method is unexported so only handles (and other internal types in this package) can be producers; it carries no runtime behavior.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry maps config names to typed Go behavior, so a rung-2 config can reference blocks and predicates by name. It is the bridge between a data config (which carries no types) and the compile-time-typed rung-1 builder: a registered entry captures its I/O reflect.Type so Load can validate the topology at load time.
A Registry holds two separate namespaces: blocks (Step/Tool/Model nodes) and predicates (Switch arm tests). They are separate because a block lowers to a node.run closure while a predicate lowers to a Switch arm's erased func; a name may appear once in each namespace without conflict. A Registry is per-Load, explicit, and created fresh by NewRegistry; there is no global mutable default.
A Registry is not safe for concurrent registration. Register all behavior on one goroutine before calling Load.
func NewRegistry ¶
func NewRegistry() *Registry
NewRegistry returns a fresh, empty Registry. Every Load takes an explicit Registry; there is no shared global, so two loaders never contend over one mutable namespace.
type Topology ¶
type Topology struct {
// Flow is the flow's name.
Flow string `json:"flow"`
// Entry is the name of the entry node (the step that consumes the flow input).
Entry string `json:"entry"`
// In and Out are the flow's pinned boundary type names, as reflect.Type.String
// renders them (for example "int" or "main.Report"). Empty if a side is unpinned.
In string `json:"in"`
Out string `json:"out"`
// Nodes are the declared nodes in insertion order.
Nodes []TopologyNode `json:"nodes"`
// Edges are the declared producer -> consumer connections in insertion order,
// including the one edge per join input.
Edges []TopologyEdge `json:"edges"`
// Branches are the declared Switch branches in insertion order.
Branches []TopologyBranch `json:"branches,omitempty"`
// Joins are the declared fan-in joins in insertion order. Each also appears in
// Nodes with kind "join"; Joins additionally records the ORDERED input ports.
Joins []TopologyJoin `json:"joins,omitempty"`
// Loops are the bounded loops Build derived from the LoopBack arms, in insertion
// order. Empty for an acyclic flow.
Loops []TopologyLoop `json:"loops,omitempty"`
}
Topology is the stable, renderer-agnostic view of a frozen flow's DECLARED shape: its nodes, the edges between them, its Switch branches, its bounded loops, and its fan-in joins, all as pure data (plain strings, no reflect.Type, no closures). It is the single structured model that every renderer and every external tool builds on: Flow.RenderMermaid derives a Mermaid string from the same internal source Topology snapshots, and a visual builder or an audit tool consumes Topology directly rather than parsing a Mermaid string or reading the opaque Digest. Every field carries a json tag, so a Topology round-trips through encoding/json without loss (see Flow.Topology).
Topology reports the same topology the Digest commits to (flow name, entry, nodes with their kinds and I/O types, edges, Switch arms, and loop structure), so a change that shifts the Digest is visible here too. It reports the DECLARED diagram, not a run: it says nothing about which arms a particular run took (see agent.RenderMermaid for the actual-ran shape).
type TopologyBranch ¶
type TopologyBranch struct {
Over string `json:"over"`
Arms []TopologyBranchArm `json:"arms"`
Else string `json:"else,omitempty"`
}
TopologyBranch is one declared Switch: the switched-over node and its ordered arms. Else names the target of the Else arm if the Switch has one, else empty (the Else arm is also present in Arms with Else set true).
type TopologyBranchArm ¶
type TopologyBranchArm struct {
Target string `json:"target"`
Else bool `json:"else,omitempty"`
LoopBack bool `json:"loopBack,omitempty"`
LoopMax int `json:"loopMax,omitempty"`
}
TopologyBranchArm is one arm of a Switch: its target node and its role. Else is true for the fallback arm (its predicate is nil). LoopBack is true for a bounded back-edge to an earlier loop head; LoopMax is that arm's iteration bound (> 0 for a loop-back arm, 0 otherwise). A forward When arm has Else and LoopBack both false and LoopMax 0. The arm predicate itself is a Go closure and is not part of this data view (it is not serializable and not part of the topology's identity).
type TopologyEdge ¶
type TopologyEdge struct {
From string `json:"from"`
To string `json:"to"`
Type string `json:"type,omitempty"`
}
TopologyEdge is one declared connection producer.Out -> consumer.In, by node name. An edge feeding a join carries the join input port's type name in Type; every other edge leaves Type empty.
type TopologyJoin ¶
type TopologyJoin struct {
Name string `json:"name"`
Inputs []string `json:"inputs"`
InTypes []string `json:"inTypes"`
Out string `json:"out"`
}
TopologyJoin is one declared fan-in: the join node's name, its ORDERED input source node names (one per merge-function parameter, in declared order), the positionally-aligned input port type names, and the join's output type name. A join also appears in Topology.Nodes with kind "join"; this records the fan-in detail Nodes cannot (the ordered inputs and their per-port types).
type TopologyLoop ¶
type TopologyLoop struct {
Head string `json:"head"`
Over string `json:"over"`
Body []string `json:"body"`
Max int `json:"max"`
}
TopologyLoop is one bounded loop Build derived from a LoopBack arm: the loop head (the back-edge target, an earlier node re-entered each iteration), the loop switch (the node the Switch is over, which carries the back-edge arm), the ordered body region (head..switch inclusive, in forward topological order), and the iteration bound Max (> 0). It mirrors the resolved loopSpec, minus the forward-order indices, which are an internal execution detail.
type TopologyNode ¶
type TopologyNode struct {
Name string `json:"name"`
Kind string `json:"kind"`
In string `json:"in"`
Out string `json:"out"`
}
TopologyNode is one declared node: its journal name, its kind as a string ("step", "tool", "model", "switch", or "join"), and its input/output type names as reflect.Type.String renders them. A join node has no single input type (it fans in several producers), so In is empty for a join; its per-port input types live in the matching TopologyJoin. An unpinned side is empty.