Build composable agent topologies via two nestable patterns: deterministic
Workflow (FSM) and dynamic Orchestrator (LLM tool-use loop). Human-in-the-Loop
(HumanNode gates, tool approval, generalized suspension) and durable persistence
(checkpoint/thaw/resume across process boundaries) are first-class concerns. The
uniform Node interface (context → context) guarantees any node type composes
with any other at any depth.
- All nodes implement
context → context(endomorphism monoid over maps, composed via Kleisli arrows). - Node types: ActionNode (wraps
Jido.Action), AgentNode (wrapsJido.Agent), FanOutNode (parallel branches), MapNode (traverse — same node over a runtime list), HumanNode (suspend for human input), DynamicAgentNode (assembles sub-agents from skills at runtime). - Nodes return
{:ok, context},{:ok, context, outcome_atom}, or{:error, reason}. HumanNode returns{:ok, context, :suspend}. - Context layers: each node's output merges under its key via deep merge. Access
upstream results with
get_in(params, [:node_key, :field]). - Directives describe side effects:
Suspend,SuspendForHuman,FanOutBranch,CheckpointAndStop.
- Suspension reasons:
:human_input_required,:approval_required, or custom atoms. ApprovalRequest: serializable struct with uniqueid,tool_call,context_snapshot.ApprovalResponse:approved | rejected | modifiedwith optionalmodifications.- Checkpoint:
Checkpoint.save/2serializes state.ChildRefreplaces live PIDs for safe serialization. - Resume:
Resume.resume/2thaws from checkpoint. Top-down: parent resumes, then re-attaches children. - Parent isolation: parent doesn't know child is paused. Rejection is internalized within child.
- DSL:
use Jido.Composer.Workflowwithname,nodes(map of atom → module),transitions(map of{state, outcome} => next_state). - Transitions are exhaustive; use
{:_, :error} => :failedas catch-all. - Custom outcomes: nodes return
{:ok, ctx, :custom_outcome}to branch. Transition map must cover all possible outcomes. - FanOutNode:
fork_fnsreturns list of{branch_key, fun}pairs. Results merge under each branch key. - MapNode: applies the same node to each element of a list from context.
MapNode.new(name: :process, over: [:generate, :items], node: MyAction). Thenodefield accepts any Node struct (ActionNode, AgentNode, FanOutNode, HumanNode, etc.) or a bare action module (auto-wrapped in ActionNode).overcan be an atom (top-level key) or a list of atoms (nested path). Results are collected as%{results: [r0, r1, ...]}. Uses FanOutBranch directives and FanOut.State internally. Empty lists produce%{results: []}.- Input preparation: map elements are merged into node params; non-map
elements are wrapped as
%{item: element}. - Missing context key or non-list value → treated as empty list.
- Use MapNode when the collection size is unknown at definition time and every element gets the same processing. Use FanOutNode when branches are heterogeneous and fixed at definition time.
- When using AgentNode as MapNode's child, the agent must be
:syncmode (the default). Async/streaming modes are not directly runnable viarun/3and will error.
- Input preparation: map elements are merged into node params; non-map
elements are wrapped as
- HumanNode: always returns
:suspendoutcome. Pair withSuspendForHumandirective for approval gates. - Terminal states:
:doneand:failedare convention defaults (with:doneas success state). Customterminal_statesrequire pairing withsuccess_states; providing one without the other is a compile error.
Five constructors plus one escape hatch cover the full range of workflow shapes:
| Constructor | What It Does | Graph Defined At | DSL Expression |
|---|---|---|---|
| Sequence | Do A, then B | Compile time | transitions map |
| Parallel | Do A and B simultaneously | Compile time | FanOutNode with branches: |
| Choice | Do A or B based on outcome | Compile time | Custom outcomes + transitions |
| Traverse | Apply A to each item in a list | Compile time | MapNode with over:, node: |
| Identity | Pass through unchanged | Compile time | No-op action |
| Bind | Compute which workflow to run next | Runtime | Orchestrator DSL |
- Constructors 1–5 → Workflow. Static graph defined at
defmoduletime. Data flows through at runtime (outcomes drive choice, collection sizes drive traverse), but graph structure is fixed. - Bind → Orchestrator. Execution path discovered at runtime by LLM.
- Mix both: Workflow with Orchestrator as a node, or Orchestrator invoking Workflows as tools.
- DSL:
use Jido.Composer.Orchestratorwithname,description,tools(list of action/agent modules). query_sync/2drives a ReAct loop: LLM picks tools → execute → feed results back → repeat until termination.termination_tool: aJido.Actionmodule whose schema defines structured output. LLM calls it as a regular tool to emit the final answer.- Tool wrapping: modules listed in
toolsare auto-converted to LLM tool descriptions viaAgentTool. - Approval gates: per-tool
requires_approval: true+approval_policyfunction. Gated tool calls emitSuspendForHuman. - Streaming:
stream: trueuses Finch directly, bypassing Req plugs. Disable streaming when using cassette/stub plugs for testing. - LLM config: DSL supports
temperature,max_tokens,stream,termination_tool,llm_opts. - Runtime configuration:
configure/2overrides strategy state afternew/0but beforequery_sync/3. Accepts:system_prompt,:nodes,:model,:temperature,:max_tokens,:req_options,:conversation. The:nodesoverride rebuilds tools/name_atoms/schema_keys internally and handles termination tool dedup. - Read accessors:
get_action_modules/1returns current node modules;get_termination_module/1returns the termination tool module. Use for read-filter-write patterns (RBAC).
- Skill: pure-data struct (
name,description,prompt_fragment,tools). Packages reusable capabilities without defining modules. Skill.assemble/2: takes a list of skills + options (:base_prompt,:model,:max_iterations,:temperature,:max_tokens,:req_options), composes prompt fragments, deduplicates tools, returns a configuredBaseOrchestratoragent ready forquery_sync.- DynamicAgentNode: Node that wraps
Skill.assemble/2+query_sync/3. Parent LLM selects skills by name viaskills: ["math", "data"]and provides ataskstring. The node looks up skills from its registry, assembles a sub-agent, runs it, and returns the result. - Inject DynamicAgentNode via
configure(agent, nodes: [dynamic_node])— thebuild_nodes/1catch-all handles custom node structs viaNode.dispatch_name/1. - Use direct
Skill.assemble/2when you want ad-hoc agents without a parent. Use DynamicAgentNode when the parent LLM should decide which skills to combine per query.
- Any node can be another Workflow or Orchestrator (arbitrary nesting).
- AgentNode wraps a
Jido.Agentas a node — the child runs its own strategy internally. - Jido.AI agents (
use Jido.AI.Agent) are auto-detected viaask_sync/3and work as first-class nodes. Composer spawns a temporary AgentServer, queries it, and shuts it down. Requires the Jido supervision tree to be running. - When used as orchestrator tools, Jido.AI agents expose
{"query": "string"}schema (not internal state fields). - Context flows top-down; child results merge into parent context under the node key.
- FanOutNode
fork_fnsreceive the current context and return branch-specific params. - Control spectrum: Workflow only (fully deterministic) → Workflow + HumanNode → Workflow containing Orchestrator → Orchestrator containing Workflow → Orchestrator only (fully adaptive).
- ReqCassette for e2e tests with recorded API responses. Never hand-craft
cassettes; delete and re-record with
RECORD_CASSETTES=true mix test. - LLMStub direct mode (
LLMStub.setup/1+LLMStub.execute/1): process-dictionary queue for strategy tests. - LLMStub plug mode (
LLMStub.setup_req_stub/2): Req.Test.stub-backed queue for DSLquery_synctests. - LLMAction retries once by default — error stubs need 2+ responses to cover the retry.
- Disable streaming (
stream: false) when cassette/stub plug is active (Finch bypasses Req plugs). - Propagate
req_optionsfor plug injection: LLMAction passes them asreq_http_optionsto ReqLLM.
config :jido, :observability, tracer: AgentObs.JidoTracerenables OpenTelemetry tracing.- AgentObs bridges Jido.Observe spans to OTel via
AgentObs.Handlers.Phoenix. - Span hierarchy: AGENT > CHAIN (iteration) > LLM + TOOL. Nested agents parent under the outer TOOL span.
- Calling LLM APIs directly; use
Orchestrator+LLMActionwhich handles tool conversion and retries. - Embedding runtime side effects in node logic; emit directives instead.
- Using
String.to_atom/1on untrusted input (node keys, outcomes). - Assuming streaming works with test plugs; always set
stream: falsein stub/cassette tests. - Skipping
mix precommitbefore commits.