Help us shape the future of Go! Take the 2026 Go Developer Survey

plan

package
v0.23.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 23, 2026 License: Apache-2.0 Imports: 27 Imported by: 0

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.Journal.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.Journal.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 (node: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 "loop_max" ({"pred":p,"to":head,"loop_max":n}), which Build lowers exactly as LoopBack.

A node may carry a human approval gate (Approval, or a config "approval" block): Run pauses before the node until the gate passes, with the agent package's own gate, records and counting rule, so a gated node's decisions verify offline as a gated tool call's do.

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/guides/plan-flows.md for the guide, including the limits this package carries (Switch arms do not reconverge; plan adds no executor of its own).

Where to start: builder.go, handle.go and wiring.go declare and wire nodes; build.go and flow.go freeze and run a Flow; gate.go gates a node on approval; conformance.go holds Conform; config.go, registry.go and inspect.go hold the flow config. The reading-order map of every package is docs/ARCHITECTURE.md in the repository.

Index

Examples

Constants

View Source
const ConfigVersion = 1

ConfigVersion is the one config format version this release reads. A config states it in its "version" key; Load and Validate refuse a config that omits it or states another. Version 1 spells every key in snake_case ("loop_max", not "loopMax").

View Source
const DescriptionVersion = 1

DescriptionVersion is the Version of a RegistryDescription this release writes and reads.

View Source
const TopologyStep = "flow:topology"

TopologyStep is the reserved journal name under which a flow run's first drive records the flow's declared Topology, as JSON (the encoding Flow.Topology's json tags define), as a StepValue just before flow:digest. TopologyDigest of the recorded Topology is the recorded digest, so a reader can draw the flow a run ran and check it is the one the run journaled (see JournaledTopology). It is informational: Run, resume, routing and recovery never read it; they use flow:digest and the nodes' own records, as before. A run started before this record existed has none.

Variables

View Source
var ErrApprovalDenied = errors.New("approval denied")

ErrApprovalDenied is returned (wrapped, naming the node) by Flow.Run when a node's approval gate denied it: a SingleApproval gate's agent.Approve(false), or an m-of-n gate whose terminal tally did not pass. The node does not run. The denial is recorded, so every later drive of the run returns it again without running the node; the run stays unfinished until it is cancelled (agent.Cancel).

View Source
var ErrApprovalExpired = fmt.Errorf("%w (%w)", agent.ErrApprovalExpired, ErrApprovalDenied)

ErrApprovalExpired is returned (wrapped, naming the node) by Flow.Run when a node's approval gate expired: its deadline (agent.ApprovalPolicy.Timeout) passed with no approval. It wraps both agent.ErrApprovalExpired and ErrApprovalDenied: an expiry is a denial in every other respect, and every later drive returns it.

Functions

func CompareAssertions added in v0.18.0

func CompareAssertions(a, b string) string

CompareAssertions relates two assertion lines of a config, for comparing two versions of it. It returns "equal" for the same assertion (spacing and quoting of a node name aside, in every form); "stronger" when b is a different assertion that holds only where a holds, which only the approval form decides ("every path to T passes approval >= K [of N] [excluding the requester]": b is stronger when it names the same T and needs at least a's K, from a set no larger than a's N, and excludes the requester wherever a does); "weaker" when both are of that form for the same T and b is not stronger or equal, so a config could satisfy b and violate a; and "" otherwise (different forms or targets, which the comparison cannot relate).

func RenameAssertion added in v0.18.0

func RenameAssertion(text string, rename func(string) string) string

RenameAssertion is the assertion line with each node name it holds replaced by rename(name) and every name written in double quotes, so two spellings of one assertion (a bare name or a quoted one, extra spaces) are the same string. A line that is not an assertion this version reads comes back with only its spaces collapsed. A nil rename keeps the names.

func TopologyDigest added in v0.14.0

func TopologyDigest(t Topology) (string, error)

TopologyDigest returns the v2 topology digest of t, the value Flow.Digest returns for a flow whose Topology is t: the same canonical, insertion-ordered serialization of the flow name, the boundary and node types with their package paths (the Canonical fields), every node's name, block and kind, every edge, every Switch arm with its predicate name, and every loop. Flow.Digest is computed through it, so the digest a run journals under flow:digest is the digest of the Topology it journals under flow:topology, and a reader can check that what it draws is what ran.

It errors on a Topology no flow produces: a node of an unknown kind, or a join node with no TopologyJoin, or one whose InCanonical does not align with its Inputs.

func Validate

func Validate(data []byte, reg *Registry) error

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.

Validate's errors carry no problem kinds, only messages (and agent.ErrConfig). A tool that wants each problem's kind (ConfigReport.Findings) calls InspectConfig or InspectConfigDescribed.

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

func Else[M any](to Consumer[M]) Arm[M]

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

func LoopBack[M any](max int, pred func(M) bool, head Consumer[M]) Arm[M]

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 (node: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.

func When

func When[M any](pred func(M) bool, to Consumer[M]) Arm[M]

When routes to `to` when pred(over.Out) is true. pred must be pure over the value: Build lowers the branch choice to its own journaled step, so a resumed run replays the recorded arm and the predicate is not re-evaluated against changed external state.

func (Arm[M]) Named added in v0.8.0

func (a Arm[M]) Named(name string) Arm[M]

Named names the arm's predicate, as RegisterPredicate names one for a config, and returns the arm. Digest commits to the name, so a flow built in Go that names its predicates the way a config does has the config's digest, and swapping two named predicates changes it. A Go predicate is a func with no stable name of its own, so an unnamed arm commits to none: the Go code, not the digest, is then what says which predicate runs.

type AssertionResult added in v0.15.0

type AssertionResult struct {
	// Text is the assertion as written, with its spaces collapsed.
	Text string `json:"text"`
	// Holds reports the assertion holds on the declared graph.
	Holds bool `json:"holds"`
	// Invalid reports the line is not an assertion this version reads, or names a node the config
	// does not declare; Message says which. It is neither held nor violated.
	Invalid bool `json:"invalid,omitempty"`
	// Vacuous reports the assertion holds only because nothing it is about is reachable: for
	// "every path to T passes ...", no path from the entry reaches T (or, for the terminal form, a
	// terminal), and for "no side effect after X", the entry reaches no X; Message says so. A flow that turned cyclic, or lost the edge to T, satisfies such a
	// line without the line checking anything.
	Vacuous bool `json:"vacuous,omitempty"`
	// Undecided reports the graph alone cannot decide it (a node's Safety is not known).
	Undecided bool `json:"undecided,omitempty"`
	// Path is a counterexample when the assertion is violated: a path of node names through the
	// declared graph (empty when none applies, as for "X reachable" when X is not).
	Path []string `json:"path,omitempty"`
	// Message says why it does not hold, what it could not decide, why it is invalid, or why it
	// holds only vacuously.
	Message string `json:"message,omitempty"`
}

AssertionResult is the verdict on one line of a flow config's assert list. InspectConfig reports one per line, in order.

func (AssertionResult) Violated added in v0.15.0

func (a AssertionResult) Violated() bool

Violated reports the assertion was decided and does not hold.

type BlockDescription added in v0.22.0

type BlockDescription struct {
	Name string `json:"name"`
	// Kind is "step", "tool" or "model".
	Kind string `json:"kind"`
	// In and Out are the block's input and output types, spelled as the topology digest spells
	// them (a named type is its package path, '.', and its name).
	In  string `json:"in"`
	Out string `json:"out"`
	// Safety is the retry-on-resume class the block declares: "readonly", "idempotent" or
	// "side_effect", the words a config's "safety" uses. A config may keep or lower it.
	Safety string `json:"safety"`
	// Gate is the approval gate the block declares in Go (a wrapped tool's own, or the Approval
	// option), nil when it declares none.
	Gate *GateDescription `json:"gate,omitempty"`
}

BlockDescription is one registered Step, Tool or Model block.

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

func New[In, Out any](name string) *Builder[In, Out]

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

func (b *Builder[In, Out]) Build() (*Flow[In, Out], error)

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

func (b *Builder[In, Out]) Edge[M any](from Producer[M], to Consumer[M])

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(context.Context, 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(context.Context, 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, 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() 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(context.Context, 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(context.Context, 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, then decodes the model's text response into O. To a model that honours Request.ResponseFormat (agent.ModelInfoOf reports it: model/openai, model/gemini) the request carries O's JSON schema (schema.For[O]) as the response format, so the provider's structured output constrains the reply to O's JSON; to any other model (model/anthropic, or a model that does not describe itself) it carries none, and the prompt must ask for the JSON. 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 node is ReadOnly by default. The node sends one prompt and decodes the reply; it calls no tool and changes nothing outside the run, and the run sees only the output the journal records. So when a call's outcome is unknown (a crash lost its result, or the call failed: the provider errored or the reply did not decode into O), the next drive calls the model again rather than halting. A re-call costs tokens and may return a different answer; nothing saw the lost one. To halt instead, lower the node's safety in a config ("safety": "side_effect") or wrap the call in a Step.

func (*Builder[In, Out]) Step

func (b *Builder[In, Out]) Step[I, O any](name string, fn func(context.Context, I) (O, error), opts ...NodeOption) Handle[I, O]

Step registers an arbitrary func(ctx, 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 runs every node as an agent.Journal.Step under its node key "node:<name>" (an attempt claim written before the body, the result after), so a crash whose outcome was never recorded HALTS the run (*agent.OutcomeUnknown, cleared with agent.ResolveHalt) rather than re-firing the body. A non-idempotent side effect is therefore safe by default, with no per-step opt-in. A Step that is not retry-safe must not pause (its body's pause is ErrConfig, as for agent.Journal.Step). See docs/guides/plan-flows.md.

Pass plan.ReadOnly() or plan.Idempotent() 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

func (b *Builder[In, Out]) Switch[M any](over Producer[M], arms ...Arm[M])

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.Journal.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 its spec's Safety (agent.Tool.Spec) on the node, so a tool the core classifies as retry-safe (ReadOnly or Idempotent) 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]) WithApproverVerifiers added in v0.14.0

func (b *Builder[In, Out]) WithApproverVerifiers(f agent.ApproverVerifierFor) *Builder[In, Out]

WithApproverVerifiers binds the resolver of each approver's signature verifier for the flow's m-of-n approval gates, as agent.Agent.WithApproverVerifiers does for an agent's: a decision counts only if it verifies under the verifier this returns for its approver. Build refuses a flow with an m-of-n gate and no resolver. A later call overrides an earlier one.

func (*Builder[In, Out]) WithModel

func (b *Builder[In, Out]) WithModel(m agent.Model) *Builder[In, Out]

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.

func (*Builder[In, Out]) WithNotifier added in v0.16.0

func (b *Builder[In, Out]) WithNotifier(n agent.Notifier, trail agent.NotifyTrail, opts ...agent.NotifyOption) *Builder[In, Out]

WithNotifier tells n, at least once and with a stable ID, when a run of the flow that Run drives halts on an unknown outcome, waits at an approval gate, or is aborted: see agent.WithNotifier, whose semantics, trail requirements and options this takes. It is a runtime binding, not part of the flow's Digest. A flow that Recover or RecoverLoop drive is also covered by the option given to them. A nil notifier or trail is ErrConfig, reported by Build.

func (*Builder[In, Out]) WithStopSwitch added in v0.21.0

func (b *Builder[In, Out]) WithStopSwitch(sw *agent.StopSwitch) *Builder[In, Out]

WithStopSwitch gives the flow a kill switch (see agent.StopSwitch and docs/guides/kill-switch.md): a run of the flow is held, as a pause (*agent.Stopped), at its start, and each node is held before its claim and, with the control run read then, after the claim and before the node's body, where a stop covers the flow (agent.StopFlow, by the flow's name), the agent that acts (an agent stop) or every run (a global stop). A held node writes no attempt marker; a won claim is recorded as not started. A flow with no switch, on a store whose control run holds a record, is refused (ErrConfig). It is a runtime binding, not part of the flow's Digest. A nil switch is ErrConfig, reported by Build.

type ConfigProblem added in v0.14.0

type ConfigProblem struct {
	Node     string `json:"node,omitempty"`
	From     string `json:"from,omitempty"`
	To       string `json:"to,omitempty"`
	Message  string `json:"message"`
	Severity string `json:"severity"` // "error" (Load refuses the config) or "warning"
}

ConfigProblem is one problem in a flow config. Node names the node, switched node or join it belongs to; From and To name the edge or arm it belongs to (Node is then empty); all three are empty for a problem of the config as a whole.

type ConfigReport added in v0.14.0

type ConfigReport struct {
	// Topology is the declared graph. With a Registry and no errors it is the Topology Load would
	// build (node kinds, types, derived loops); otherwise it is read from the config alone, with no
	// types and no node kinds but a join's.
	Topology Topology
	// Digest is the digest a run of the flow would journal (TopologyDigest of Topology), set only
	// with a Registry and no errors: it commits to types, which only the Registry knows.
	Digest string
	// Approvals are the approval gates the config declares, by node.
	Approvals map[string]agent.ApprovalPolicy
	// Problems are every error and warning found, in config order.
	Problems []ConfigProblem
	// Findings are the structure behind the errors in Problems (see Finding), one for each, and every
	// type problem even past the hundred Problems lists.
	Findings []Finding
	// Typed reports a Registry was given, so blocks, predicates, merges and types were checked as
	// Load checks them. Without one, the check is structural.
	Typed bool
	// Safety is each node's crash behavior: exact with a Registry (the Safety the block registers,
	// lowered by the config's safety), and without one known only where the config states it.
	Safety map[string]NodeSafety
	// Assertions are the verdicts on the config's assert lines, one per line, in order.
	Assertions []AssertionResult
	// Gated reports, by node, that every path from the entry to it passes an approval gate (its
	// own included): the run cannot reach it without a human decision (vacuously so for a node the
	// entry cannot reach). Without a Registry only the gates the config declares count.
	Gated map[string]bool
}

ConfigReport is what InspectConfig finds in a flow config: the graph it declares, the digest a run of it would journal, its approval gates, and its problems, each attached to the node, edge or arm it belongs to.

func InspectConfig added in v0.14.0

func InspectConfig(data []byte, reg *Registry, opts ...InspectOption) (r ConfigReport)

InspectConfig checks a flow config without running or building a Flow, for a viewer or a CI check. It never panics on bad input: every failure is a ConfigProblem.

Without a Registry (reg nil) it runs the structural checks, which need no Go code: the config parses as Load parses it (strictly, version 1); node names are unique; each wiring element is exactly one of an edge, a switch or a join; every edge, arm, else, join input and the entry name a declared node or join; every safety string is known; and every approval block is a valid policy. Blocks, predicates, merges and types are not checked.

With a Registry it also runs Load's own checks that do not need the flow's In and Out (the checks Validate runs) and Build's whole-graph checks, with In taken as the entry node's input type and Out as the terminal nodes' output type, which are the only In and Out Load accepts. The digest it reports is then the digest Load's flow would journal.

Each problem is attached to the node, edge or arm its message names. Every approval block is listed in Approvals and on its node in Topology; one that is not a valid policy is an error. An m-of-n gate is checked as Load checks it with approver verifiers bound (WithLoadedApproverVerifiers): the keys are not the config's. One warning is not a load error: a Switch with no else, since a value no arm matches fails the run.

Safety reports each node's crash behavior, and Assertions the verdict on each line of the config's "assert" list (which Load ignores), decided by reachability on the declared graph:

every path to <node> passes approval >= K [of N]
every path to <node> passes <node>
every path to a terminal passes <node>
no side effect after <node>
<node> reachable
<node> unreachable

A node is named bare or in double quotes. A gate counts toward "approval >= K of N" when it needs at least K decisions from at most N named approvers (a one-decision gate counts only for "approval >= 1"); without a Registry only the gates the config declares count. A violated, undecided or ill-formed assertion is an error, a violated one with a shortest counterexample path.

func InspectConfigDescribed added in v0.22.0

func InspectConfigDescribed(data []byte, d RegistryDescription, opts ...InspectOption) ConfigReport

InspectConfigDescribed is InspectConfig for a program's registrations known only as a description: it runs the same checks (the same code) on a Registry built from d, so blocks, predicates, merges, types, safety, gates and the digest are checked without the program.

What differs from InspectConfig with the real Registry:

  • Types are told apart by name. Two distinct types spelled the same (a function-local type of the same name in two functions) are one type here, as the digest already cannot tell them apart; Load, comparing reflect types, would refuse an edge between them.
  • A type is written with its package path wherever InspectConfig writes reflect's short form (in messages, and in Topology's In and Out), because that is what d holds.
  • d is data from a file and may lie; the answer is only as good as the description. A d that is not valid (RegistryDescription.Validate) is reported as an error problem and the config is checked structurally.

A process keeps one stand-in type for each distinct type name it has been given (reflect keeps them), so it admits at most 20000 names and 4 MiB of names in all, about 25 MB; past that the result is an error problem and the config is checked structurally, until the process restarts. One description counts against that budget with every distinct type name it holds, at most a quarter of it (RegistryDescription.Validate). A host that checks descriptions from sources it does not trust should do it in a short-lived process.

Experimental: may change in any 0.x release.

func (ConfigReport) Errors added in v0.14.0

func (r ConfigReport) Errors() bool

Errors reports whether any problem is an error.

func (ConfigReport) UngatedPath added in v0.18.0

func (r ConfigReport) UngatedPath(node string) []string

UngatedPath is a shortest path of node names from the entry to node that passes no approval gate, or nil when every path to node passes one (or the entry cannot reach it): the evidence behind Gated[node] being false.

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 Finding added in v0.22.0

type Finding struct {
	Kind    ProblemKind `json:"kind"`
	Names   []string    `json:"names,omitempty"`
	Got     string      `json:"got,omitempty"`
	Want    string      `json:"want,omitempty"`
	Message string      `json:"message"`
}

Finding is the structure behind one error in a ConfigReport: what kind of problem it is and the names it is about, so that a tool need not read the Message to tell problems apart. A Finding is found from the config and the registered types alone, so it is present beside any other error.

Every error in ConfigReport.Problems has exactly one Finding with the same Message (Findings are in the order of the errors). The one exception is the line "and N more type problems" that ends a long list of type problems: Problems lists the first 100, and Findings has all of them. Warnings have no Finding.

The errors of Load and Validate carry no kinds, only messages; a tool that wants kinds calls InspectConfig or InspectConfigDescribed.

A problem is identified by its Kind, Names, Got and Want, never by its Message, which shortens long names and names a wiring element by its position. Names hold the names as the config has them, in full; they hold no position. Got and Want are empty for a kind that does not say them.

Stability (0.x): a kind's name, the order of its Names and the format of its Got and Want do not change; kinds may be added, so a tool must ignore a kind it does not know; "other" may be split into new kinds; Message is for people and may change.

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.Journal.Step.

func Load

func Load[In, Out any](data []byte, reg *Registry, opts ...LoadOption) (*Flow[In, Out], error)

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.

Load's errors carry no problem kinds, only messages (and agent.ErrConfig). A tool that wants each problem's kind (ConfigReport.Findings) calls InspectConfig or InspectConfigDescribed.

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. A config larger than 16 MiB is refused with an error wrapping agent.ErrConfig, and r is not read past the byte that shows it is.

func (*Flow[In, Out]) Conform

func (f *Flow[In, Out]) Conform(ctx context.Context, store *agent.Journal, 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 a declared flow (docs/guides/plan-flows.md): prove the run followed the declared graph, or point at where it diverged. Conform reads history via agent.Journal.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

func (f *Flow[In, Out]) Digest() string

Digest returns a deterministic SHA-256 hash (hex-encoded) of this frozen flow's DECLARED topology: the flow name, every node's name, block, kind, input type and output type (each type with its full package path), every edge, and every Switch and its ordered arms with their predicate names, and every node's approval gate (written only for a gated node, so an ungated flow's digest does not depend on it). This is the v2 digest (domain tag "bide.plan.topology.v2"); DigestV1 is the earlier one. 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/plan-flows.md, "Cryptographic conformance".

The digest commits to topology and to the NAMES of the behaviour it wires, not to node bodies: it hashes which registered block a node runs and which registered predicate an arm tests (for a flow built in Go, the names given with BlockName and Arm.Named; a node's block defaults to its own name and an unnamed predicate commits to none), but not the Go inside a block or predicate. This mirrors Conform, which is declaration-vs-journal, not value verification.

Run refuses to resume a run whose journaled digest is not this one, including a run journaled under v1: v1 does not commit to block or predicate names, so it cannot show the run started under this flow rather than one that runs another block or predicate.

func (*Flow[In, Out]) DigestV1 added in v0.8.0

func (f *Flow[In, Out]) DigestV1() string

DigestV1 returns the v1 topology digest (domain tag "bide.plan.topology.v1"), which Run recorded before v2. It does not commit to block or predicate names and writes types without their package paths. Use it only to check a proof of a flow:digest record journaled by an earlier version; Run and Conform accept only Digest.

func (*Flow[In, Out]) FlushNotifications added in v0.16.0

func (f *Flow[In, Out]) FlushNotifications(ctx context.Context) error

FlushNotifications waits for the flow's notifier to finish the deliveries in flight, for every journal the flow has run against, as agent.Agent.FlushNotifications does. It does nothing for a flow with no notifier.

func (*Flow[In, Out]) RenderMermaid

func (f *Flow[In, Out]) RenderMermaid() string

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]) ResolveHalt added in v0.9.0

func (f *Flow[In, Out]) ResolveHalt(ctx context.Context, store *agent.Journal, ref agent.HaltRef, out agent.Outcome, opts ...agent.ResolveOption) error

ResolveHalt clears a halt of a node of this flow, as agent.ResolveHalt does, once it has checked the resolution against the flow: ref must name a node of this flow (the halt's Ref: a node key, a loop iteration's key for a node in a loop body, or the key of a Step a node's body ran), the run must be a run of this flow (its recorded start names this flow, and its recorded topology digest is this flow's), and a successful outcome's Result must decode as the node's output type, so the next Run can feed it downstream. A resolution agent.ResolveHalt records is final, so one the flow cannot read would leave the run unable to continue; ResolveHalt refuses it (ErrConfig) instead and records nothing. A Step a node's body ran returns a type the flow does not declare, so its Result is not checked.

if halt, ok := errors.AsType[*agent.OutcomeUnknown](err); ok {
    // confirm out of band that the charge went through, then record the node's output
    err = flow.ResolveHalt(ctx, store, halt.Ref(), agent.Outcome{Result: receipt})
}

func (*Flow[In, Out]) Run

func (f *Flow[In, Out]) Run(ctx context.Context, store *agent.Journal, runID string, in In, opts ...agent.RunOption) (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 runs each node as an agent.Journal.Step, exactly as plain Go control flow over agent.Journal.Step would (see docs/guides/plan-flows.md). It therefore inherits the Step's guarantees unchanged: a completed node returns its recorded value without re-running (at-most-once by name), and a resumed run replays recorded nodes rather than re-executing them.

Every node is a Step, lowered through the engine's step hook under its node key, so it runs under the Step's attempt claim with no per-node opt-in: a node that is not retry-safe claims an attempt marker before its body runs. On resume, a node whose live marker has no result may have fired its effect, so Run HALTS with *agent.OutcomeUnknown (Op: OpRef{Kind: OpStep, ID: its node key}) rather than re-run the body. Clear it with ResolveHalt once the true outcome is known, recording the node's output; the next Run continues past the node without running its body. An attempt the driver provably never started (it was cancelled, or its store failed, after the claim and before the body) is recorded as not started, and the next Run re-attempts the node under a numbered marker instead of halting. A node marked ReadOnly or Idempotent writes no marker and re-runs after a crash. A node that is not retry-safe must not pause: a body that returns a pause is ErrConfig, as for agent.Journal.Step. An agent.Journal.Step (or agent.Journal.Parallel task) a node's body runs for runID is recorded under the node's key ("node:<node>:step:<name>", per iteration in a loop body), so each loop iteration runs its own.

A Switch is lowered as its own record: Run reads the switched node's journaled output, evaluates the arm predicates ONCE, and records the chosen arm target under "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.

Run refuses (ErrConfig) a run ID agent.Run refuses, and holds the run to how it started. The first drive records run:start with Kind agent.RunKindFlow, the flow's name, and the JSON of in (see agent.RunStart); a later drive under another flow name, with an input that differs as canonical JSON, or of a run an Agent started, is ErrConfig. It then records the flow's topology digest, and a resume under a flow whose digest differs is ErrConfig too. When the terminal node finishes, Run records run:complete with the flow's name and its output: a later drive with the run's input returns that output, whatever the flow's topology is now, and recovery skips the run.

Journal keys Run writes, all under prefixes agent reserves, so no Step a node body runs can collide with them:

  • "run:start" -- the run's start (see agent.RunStart), recorded first.
  • "flow:digest" -- the frozen topology digest (StepValue whose Result is the JSON-encoded hex digest), memoized on resume. Conform verifies it equals the current flow's Digest().
  • "node:"+N -- node N's result (StepValue whose Result is the JSON-encoded output); "node:iter:<i>:"+N for iteration i of a bounded loop's body.
  • the Step's claim bookkeeping for a node that is not retry-safe: "attempt:step:node:"+N (StepAttempt), numbered re-attempts "attempt:retry:<g>:step:node:"+N, and not-started records.
  • "switch:"+over -- a switched node's journaled arm choice (StepValue whose Result is the JSON-encoded chosen target step name); "switch:iter:<i>:"+over for a loop Switch's iteration i.
  • "run:complete" -- the run's completion: the flow's name and its output.

A node with an approval gate (Approval, a wrapped tool's own gate, or a config "approval" block) is held at its gate before its Step, exactly as an agent's gated tool call is: Run returns *agent.ApprovalPending until the gate passes, and an error wrapping ErrApprovalDenied once it denies, without running the node. The gate's ToolUseID is the node's key, its ToolName the node's name, and its Args the node's input as JSON; it writes the agent's approval records under that key: "approval-request:<key>" (agent.ApprovalRequest, before the first pause), "approval-pending:<key>", the decisions agent.Approve and agent.SubmitDecision record, and "approval-tally:<key>" (an m-of-n gate's terminal tally, before the node runs). In a loop body the key is the iteration's, so each iteration is approved on its own.

opts takes agent.WithIdentity alone (any other option is ErrConfig): the identity the run acts under, as an agent run's. Without it the identity ctx carries applies (a flow run started inside an agent run's tool call inherits that run's). The run journals the identity's principal (OnBehalfOf, AuthorityRef) with its start, in its run:principal and run:start, as an agent run does: a later drive under another principal is ErrConfig, a drive that passes none runs under the journaled one, and a run recorded with none (every flow run an earlier version started) keeps none. The principal is the requester an approval gate with ExcludePrincipal excludes, and the nodes' code and the runs they start see it (agent.IdentityFrom).

func (*Flow[In, Out]) Topology

func (f *Flow[In, Out]) Topology() 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 GateDescription added in v0.22.0

type GateDescription struct {
	// Single is the one-decision gate agent.SingleApproval returns; Need is then 0 and Approvers empty, as
	// the topology carries it.
	Single bool `json:"single,omitempty"`
	// Need, Approvers and ExcludePrincipal are the agent.ApprovalPolicy fields of the same names.
	Need             int      `json:"need"`
	Approvers        []string `json:"approvers,omitempty"`
	ExcludePrincipal bool     `json:"exclude_principal,omitempty"`
	// Timeout is the gate's deadline as a Go duration ("48h0m0s"), empty for none.
	Timeout string `json:"timeout,omitempty"`
}

GateDescription is an approval gate declared in Go.

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.

func (Handle[I, O]) Name

func (h Handle[I, O]) Name() string

Name returns the durable journal-step key this handle names.

type InspectOption added in v0.22.0

type InspectOption func(*inspectOptions)

InspectOption changes what InspectConfig and InspectConfigDescribed check.

func AllowUnusedBlocks added in v0.22.0

func AllowUnusedBlocks() InspectOption

AllowUnusedBlocks makes a registered block the config does not use no problem. Load and Validate refuse it (a registration nothing uses is drift), and so does a check without this option; a tool that lists the blocks a config could use, as the flow editor's palette does, passes it so that the blocks it has not placed yet do not stop the check from reading the ones it has.

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 WithLoadedApproverVerifiers added in v0.14.0

func WithLoadedApproverVerifiers(f agent.ApproverVerifierFor) LoadOption

WithLoadedApproverVerifiers binds the resolver of each approver's signature verifier for a loaded flow's m-of-n approval gates: the loader's equivalent of Builder.WithApproverVerifiers. A config with an m-of-n "approval" block loaded without it fails Build, naming the node.

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.

func WithLoadedNotifier added in v0.16.0

func WithLoadedNotifier(n agent.Notifier, trail agent.NotifyTrail, opts ...agent.NotifyOption) LoadOption

WithLoadedNotifier binds a notifier to a loaded flow: the loader's equivalent of Builder.WithNotifier.

func WithLoadedStopSwitch added in v0.21.0

func WithLoadedStopSwitch(sw *agent.StopSwitch) LoadOption

WithLoadedStopSwitch binds a kill switch to a loaded flow: the loader's equivalent of Builder.WithStopSwitch.

type MergeDescription added in v0.22.0

type MergeDescription struct {
	Name string `json:"name"`
	// Inputs are the types of the merge's ordered inputs; its arity is len(Inputs).
	Inputs []string `json:"inputs"`
	// Out is the merged result type.
	Out string `json:"out"`
	// Safety and Gate are as for a BlockDescription.
	Safety string           `json:"safety"`
	Gate   *GateDescription `json:"gate,omitempty"`
}

MergeDescription is one registered fan-in merge block.

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 Approval added in v0.14.0

func Approval(p *agent.ApprovalPolicy) NodeOption

Approval gates the node on p, a human approval policy, exactly as agent.WithApproval gates a tool: Run pauses before the node runs, returning *agent.ApprovalPending, until the gate passes, and a denied gate stops the run with ErrApprovalDenied (see Flow.Run). agent.SingleApproval() is the one-decision gate, decided with agent.Approve; any other policy is m-of-n, decided with agent.SubmitDecision and verified with the flow's approver verifiers (WithApproverVerifiers). The pause's ToolUseID is the node's key ("node:<name>", "node:iter:<n>:<name>" in a loop body) and its ToolName the node's name.

A node carries one gate: Approval on a Tool whose agent tool declares another gate is an error at Build, as is a nil p, so an option can neither loosen nor remove a tool's own gate.

func BlockName added in v0.8.0

func BlockName(name string) NodeOption

BlockName names the behaviour a node built in Go runs, the way a config node names its registered block. Digest commits to it; without it a node's block is its own name. Give a node the block name its config counterpart references (for a join, the merge block) so the Go flow and the config flow have one digest. A Registry ignores it: a registered block's name is the one it was registered under.

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). Like ReadOnly, it sets only the retry classification and keeps any approval gate the node already carries.

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. Its Step writes no attempt marker, and after an ambiguous mid-node crash (the body ran, its result was lost) Run RE-RUNS the body rather than halting. Use it for a node that only reads.

It sets only the retry classification: an approval gate the node already carries (a wrapped agent tool's) is kept, so the option cannot switch a gate off.

type NodeSafety added in v0.15.0

type NodeSafety string

NodeSafety is how a node behaves when a crash cuts it off mid-run, as a risk map shows it.

const (
	// SafetyReadOnly: no external effect; a resume re-runs the node.
	SafetyReadOnly NodeSafety = "read-only"
	// SafetyIdempotent: an effect that is safe to repeat; a resume re-runs the node.
	SafetyIdempotent NodeSafety = "idempotent"
	// SafetySideEffect: an effect that must not fire twice; a resume after a crash mid-node halts
	// (*agent.OutcomeUnknown) and asks for the outcome.
	SafetySideEffect NodeSafety = "side effect"
	// SafetyUnknown: the structural check cannot tell (the block is not registered and the config
	// states no safety).
	SafetyUnknown NodeSafety = "unknown"
)

The safety classes InspectConfig reports.

func (NodeSafety) RerunsOnCrash added in v0.15.0

func (s NodeSafety) RerunsOnCrash() bool

RerunsOnCrash reports whether a resume re-runs a node of this class that a crash cut off (read-only and idempotent); a side effect halts and asks for the outcome instead, and an unknown class is not known to do either.

type PredicateDescription added in v0.22.0

type PredicateDescription struct {
	Name string `json:"name"`
	// Switched is the type of the value the predicate tests: the output type of the node a Switch
	// over it must follow.
	Switched string `json:"switched"`
}

PredicateDescription is one registered Switch predicate.

type ProblemKind added in v0.22.0

type ProblemKind string

ProblemKind names the kind of a Finding. The values are stable (see Finding): they are written to JSON and compared by tools.

const (
	// ProblemEdgeTypes: an edge joins steps of other types. Names are [from, to]; Got is the type the
	// producer gives and Want the type the consumer takes.
	ProblemEdgeTypes ProblemKind = "edge_types"
	// ProblemArmTypes: a switch arm's predicate tests another type than the switched node gives. Names
	// are [switched node, predicate, target]; Got is the type the predicate tests, Want the type the node gives.
	ProblemArmTypes ProblemKind = "arm_types"
	// ProblemJoinInputTypes: a join input does not fit its merge. Names are [join, input]; Got is the type
	// the input gives, Want the type the merge takes there.
	ProblemJoinInputTypes ProblemKind = "join_input_types"
	// ProblemTerminalTypes: a terminal step reached from the entry gives another type than the flow's
	// output. Names are [step]; Got is its type, Want the flow's output type.
	ProblemTerminalTypes ProblemKind = "terminal_types"
	// ProblemSafetyAboveRegistration: a node states a retry safety above what its block registers in Go,
	// which a config may not claim. Names are [node]; Got is the stated safety and Want the registered one.
	ProblemSafetyAboveRegistration ProblemKind = "safety_above_registration"
	// ProblemLoopNoExitArm: a loop's switch has no arm or else that leaves it. Names are [switch].
	ProblemLoopNoExitArm ProblemKind = "loop_no_exit_arm"
	// ProblemLoopMoreThanOneBackArm: a switch has more than one loop-back arm. Names are [switch].
	ProblemLoopMoreThanOneBackArm ProblemKind = "loop_more_than_one_back_arm"
	// ProblemLoopHeadNotAncestor: a loop-back arm targets a node that does not reach its switch. Names
	// are [switch, head].
	ProblemLoopHeadNotAncestor ProblemKind = "loop_head_not_ancestor"
	// ProblemLoopBodyNotSimple: a node lies between a loop's head and switch but is not on the loop path.
	// Names are [switch, node].
	ProblemLoopBodyNotSimple ProblemKind = "loop_body_not_simple"
	// ProblemLoopInnerSwitch: a step inside a loop has a switch of its own. Names are [node, loop switch].
	ProblemLoopInnerSwitch ProblemKind = "loop_inner_switch"
	// ProblemLoopEdgeLeaves: an edge leaves a loop's body other than through its switch's exit arms.
	// Names are [from, to, loop switch].
	ProblemLoopEdgeLeaves ProblemKind = "loop_edge_leaves"
	// ProblemLoopEdgeEnters: an edge enters a loop's body past its head. Names are [from, to, loop switch,
	// head].
	ProblemLoopEdgeEnters ProblemKind = "loop_edge_enters"
	// ProblemOther: every other problem. Names, Got and Want are empty; only Message says what is wrong.
	// An "other" finding may become a specific kind in any 0.x release, so a tool treats "other" and a
	// kind it does not know alike: as an error it cannot classify.
	ProblemOther ProblemKind = "other"
)

The kinds a Finding can have. Each says, in its comment, which names it carries (in order) and what Got and Want hold. Kinds may be added in any 0.x release; a kind is never renamed or removed, and its Names order and Got and Want formats never change.

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.

func (*Registry) Describe added in v0.22.0

func (r *Registry) Describe() RegistryDescription

Describe returns what r has registered (see RegistryDescription). It is read-only and sorted by name, so registrations of distinct names give the same description whatever order they were made in (with a duplicate name the first registration wins, and Errors follows registration order). Like Register, it is not safe to call while another goroutine registers.

Experimental: part of the editor's typed mode, and may change in any 0.x release.

Example
package main

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/bide-ai/bide/agent"
	"github.com/bide-ai/bide/plan"
)

type order struct{ ID int }
type receipt struct{ Status string }

func main() {
	reg := plan.NewRegistry()
	_ = reg.RegisterStep("price", func(_ context.Context, o order) (order, error) { return o, nil }, plan.ReadOnly())
	_ = reg.RegisterStep("refund", func(_ context.Context, o order) (receipt, error) { return receipt{"paid"}, nil },
		plan.Approval(&agent.ApprovalPolicy{Need: 2, Approvers: []string{"ops", "finance"}}))
	_ = reg.RegisterPredicate("large", func(o order) bool { return o.ID > 100 })

	// A program writes this to a file for an editor or a check to read without the program.
	out, _ := json.MarshalIndent(reg.Describe(), "", "  ")
	fmt.Println(string(out))
}
Output:
{
  "version": 1,
  "blocks": [
    {
      "name": "price",
      "kind": "step",
      "in": "github.com/bide-ai/bide/plan_test.order",
      "out": "github.com/bide-ai/bide/plan_test.order",
      "safety": "readonly"
    },
    {
      "name": "refund",
      "kind": "step",
      "in": "github.com/bide-ai/bide/plan_test.order",
      "out": "github.com/bide-ai/bide/plan_test.receipt",
      "safety": "side_effect",
      "gate": {
        "need": 2,
        "approvers": [
          "ops",
          "finance"
        ]
      }
    }
  ],
  "predicates": [
    {
      "name": "large",
      "switched": "github.com/bide-ai/bide/plan_test.order"
    }
  ],
  "merges": []
}

func (*Registry) RegisterJoin2 added in v0.11.0

func (r *Registry) RegisterJoin2[A, B, O any](name string, fn func(context.Context, A, B) (O, error), opts ...NodeOption) error

RegisterJoin2 registers a fixed-arity fan-in merge func(ctx, 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.

opts set the merge block's Safety, as for RegisterStep: with none, a loaded join is a side effect and halts on an ambiguous crash, like a hand-built Join. Only Go code can mark a merge retry-safe (ReadOnly, Idempotent); a config join's "safety" may only lower what opts declare.

func (*Registry) RegisterJoin3 added in v0.11.0

func (r *Registry) RegisterJoin3[A, B, C, O any](name string, fn func(context.Context, A, B, C) (O, error), opts ...NodeOption) error

RegisterJoin3 is RegisterJoin2 for arity three: it registers a fan-in merge func(ctx, 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. opts set the merge block's Safety, as for RegisterJoin2.

func (*Registry) RegisterModel added in v0.11.0

func (r *Registry) RegisterModel[I, O any](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.

The block's Safety defaults to ReadOnly, as Builder.Model's does: a call whose outcome is unknown is made again on the next drive. A config node's "safety" lowers it ("side_effect" halts instead).

func (*Registry) RegisterPredicate added in v0.11.0

func (r *Registry) RegisterPredicate[M any](name string, pred func(M) bool) error

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 (*Registry) RegisterStep added in v0.11.0

func (r *Registry) RegisterStep[I, O any](name string, fn func(context.Context, I) (O, error), opts ...NodeOption) error

RegisterStep registers an arbitrary func(ctx, 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 (*Registry) RegisterTool added in v0.11.0

func (r *Registry) RegisterTool[I, O any](name string, t agent.Tool, opts ...NodeOption) error

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, or a tool the agent's wrapper check refuses (as agent.New would), is an error, surfaced at Load and returned here for inline checking.

Safety AUTO-DERIVES from the tool's spec (agent.Tool.Spec), 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.

type RegistryDescription added in v0.22.0

type RegistryDescription struct {
	// Version is DescriptionVersion.
	Version int `json:"version"`
	// Blocks are the Step, Tool and Model blocks, by name.
	Blocks []BlockDescription `json:"blocks"`
	// Predicates are the Switch predicates, by name.
	Predicates []PredicateDescription `json:"predicates"`
	// Merges are the fan-in merge blocks, by name. They are a namespace of their own: a name may
	// be both a block and a merge.
	Merges []MergeDescription `json:"merges"`
	// Errors lists the registrations the Registry refused or Build would refuse, so a description
	// with any is of a program Load will not load: a duplicate name or a tool the agent refuses (in
	// registration order; a duplicate never replaces the first registration, so the lists above hold
	// the first), then each block or merge whose approval gate Validate refuses, by name.
	Errors []string `json:"errors,omitempty"`
}

RegistryDescription is what a Registry has registered, as data: names, kinds, type names, the safety and the approval gate each block declares in Go. It holds no functions and no reflect values, so it can be written to a file, shown by an editor, or checked against a config without the program that registered the blocks. Prompts and output schemas are left out.

Experimental: the description and its JSON form may change in any 0.x release.

func (RegistryDescription) Validate added in v0.22.0

func (d RegistryDescription) Validate() error

Validate reports whether d is a description this release reads: its version, no registration errors (a program whose Registry refused something does not load), every name and type name present, printable and within bounds, every kind and safety one of the known words, every gate a policy agent.ApprovalPolicy.Validate accepts, no name twice in a namespace, and a merge of arity two or three (the arities Registry registers). A Describe result is valid when its Errors is empty (Describe reports what Validate would refuse); a description read from a file need not be.

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"`
	// InCanonical and OutCanonical are the same types written with every named type qualified
	// by its full package path, as the Digest commits to them ("?" for an unpinned side).
	InCanonical  string `json:"in_canonical"`
	OutCanonical string `json:"out_canonical"`
	// 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 snake_case 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).

func JournaledTopology added in v0.14.0

func JournaledTopology(recs []agent.Record) (t Topology, digest string, ok bool, err error)

JournaledTopology reads the topology a flow run journaled (TopologyStep) from recs, the run's journal, and checks it against the run's flow:digest record: ok is false when the run has no topology record, and err is non-nil when one does not decode, or when the digest computed from it (TopologyDigest) is not the journaled digest, or the run journaled no digest.

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"`
	Pred     string `json:"pred,omitempty"`
	Else     bool   `json:"else,omitempty"`
	LoopBack bool   `json:"loop_back,omitempty"`
	LoopMax  int    `json:"loop_max,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).

Pred is the name of the arm's predicate, which the Digest commits to (Arm.Named, or the registered predicate a config arm references); empty for an Else arm and an unnamed predicate.

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 TopologyGate added in v0.14.0

type TopologyGate struct {
	Single           bool     `json:"single,omitempty"`
	Need             int      `json:"need,omitempty"`
	Approvers        []string `json:"approvers,omitempty"`
	ExcludePrincipal bool     `json:"exclude_principal,omitempty"`
	// TimeoutMS is the gate's deadline in milliseconds (ApprovalPolicy.Timeout), zero for none.
	TimeoutMS int64 `json:"timeout_ms,omitempty"`
}

TopologyGate is a node's approval gate: Single for agent.SingleApproval (one decision, recorded with agent.Approve), otherwise Need signed decisions of the Approvers, in policy order (an m-of-n gate), never counting the run's requester when ExcludePrincipal is set (agent.ApprovalPolicy.ExcludePrincipal).

func (*TopologyGate) TimeoutText added in v0.18.0

func (g *TopologyGate) TimeoutText() string

TimeoutText is the gate's deadline as a short duration ("24h", "1h30m", "90s"), or "" for a gate with none (or a nil gate). It is the text the viewer, the diff and RenderMermaid show.

type TopologyJoin

type TopologyJoin struct {
	Name        string   `json:"name"`
	Inputs      []string `json:"inputs"`
	InTypes     []string `json:"in_types"`
	InCanonical []string `json:"in_canonical"`
	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).

InCanonical is InTypes with full package paths, as the Digest commits to them.

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         TopologyNodeKind `json:"kind"`
	In           string           `json:"in"`
	Out          string           `json:"out"`
	Block        string           `json:"block"`
	InCanonical  string           `json:"in_canonical"`
	OutCanonical string           `json:"out_canonical"`
	// Gate is the node's approval gate, nil for an ungated node. The Digest commits to it, so a
	// paused run cannot be resumed under a flow whose gate was removed or changed.
	Gate *TopologyGate `json:"gate,omitempty"`
}

TopologyNode is one declared node: its journal name, its kind, 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.

Block, InCanonical and OutCanonical are what the Digest commits to beyond the names: the registered block the node runs, and its input and output types with full package paths ("?" for an unpinned side; a join's InCanonical is "?", as it has no single input).

type TopologyNodeKind added in v0.8.0

type TopologyNodeKind string

TopologyNodeKind is the kind of a declared node, as Topology reports it. The set is closed: NodeKindStep, NodeKindTool, NodeKindModel, NodeKindSwitch, and NodeKindJoin.

const (
	// NodeKindStep is a Go step (Builder.Step, RegisterStep).
	NodeKindStep TopologyNodeKind = "step"
	// NodeKindTool is a node that calls an agent tool (Builder.Tool, RegisterTool).
	NodeKindTool TopologyNodeKind = "tool"
	// NodeKindModel is a node that calls the flow's model (Builder.Model, RegisterModel).
	NodeKindModel TopologyNodeKind = "model"
	// NodeKindSwitch is a journaled branch-choice node.
	NodeKindSwitch TopologyNodeKind = "switch"
	// NodeKindJoin is a fan-in node that merges several producers' outputs.
	NodeKindJoin TopologyNodeKind = "join"
)

The node kinds a Topology reports.

Directories

Path Synopsis
internal
digesttypes/a/model
Package model is a test fixture: packages a/model and b/model both declare model.Req, a pair of distinct types whose reflect.Type.String() is the same ("model.Req"), so plan's digest tests can show the digest tells them apart by their package path.
Package model is a test fixture: packages a/model and b/model both declare model.Req, a pair of distinct types whose reflect.Type.String() is the same ("model.Req"), so plan's digest tests can show the digest tells them apart by their package path.
digesttypes/b/model
Package model is a test fixture: packages a/model and b/model both declare model.Req, a pair of distinct types whose reflect.Type.String() is the same ("model.Req"), so plan's digest tests can show the digest tells them apart by their package path.
Package model is a test fixture: packages a/model and b/model both declare model.Req, a pair of distinct types whose reflect.Type.String() is the same ("model.Req"), so plan's digest tests can show the digest tells them apart by their package path.
Package planview embeds bide view's flow-config page in a program, with the program's plan.Registry, so the page checks a config the way plan.Load does: blocks, predicates, merges and types, as well as structure, and shows the digest a run of the flow would journal.
Package planview embeds bide view's flow-config page in a program, with the program's plan.Registry, so the page checks a config the way plan.Load does: blocks, predicates, merges and types, as well as structure, and shows the digest a run of the flow would journal.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL