Compose deterministic workflows
Connect typed steps, parallel branches, and first-success races without asking a model to control every transition.
Agent or workflow?
Use an Agent when the next action genuinely needs model judgment. Use a workflow when transitions are business rules you can express deterministically. Most production systems combine both: deterministic orchestration around bounded Agent steps.
This keeps approval, compensation, fan-out, and completion rules in typed code instead of hiding them in a prompt.
Install and run the smallest workflow
Workflows are included in the default runtime feature. Start with a provider-free
step so you can understand orchestration before adding model calls.
[dependencies]
anyhow = "1"
futures-executor = "0.3"
runifold = "=0.9.0"
serde_json = "1"use anyhow::Context;
use runifold::{
Budget, BudgetTracker, CapabilitySet, RunContext, Workflow, WorkflowStep,
WorkflowStepError, WorkflowStepFuture,
};
use serde_json::{Value, json};
struct NormalizeOrder;
impl WorkflowStep for NormalizeOrder {
fn execute<'a>(
&'a self,
input: Value,
_run: &'a RunContext,
) -> WorkflowStepFuture<'a> {
Box::pin(async move {
let order_id = input["order_id"]
.as_str()
.ok_or_else(|| WorkflowStepError::Execution("missing order_id".to_owned()))?;
Ok(json!({ "order_id": order_id, "normalized": true }))
})
}
}
fn main() -> anyhow::Result<()> {
let workflow = Workflow::builder("order-intake")
.version(1)
.step("normalize", NormalizeOrder, CapabilitySet::new())
.build()
.context("failed to build workflow")?;
let run = RunContext::root(
BudgetTracker::new(Budget::default()),
CapabilitySet::new(),
);
let outcome = futures_executor::block_on(
workflow.run(json!({ "order_id": "ord_42" }), &run),
)?;
println!("{}", outcome.output);
Ok(())
}Run it with cargo run. The output of one step becomes the input of the next;
outcome.output is the final canonical value and outcome.usage contains the
budget actually consumed. A build failure means the graph is invalid; a run
failure means a step, capability, budget, deadline, or cancellation boundary
stopped execution.
Sequential steps
A workflow definition gives every step a stable StepId, typed input/output,
and an explicit failure policy. Pass only the data needed by the next step.
Good step boundaries are independently retryable and observable: fetch a record, classify it, request approval, apply an effect, persist the result. Avoid one giant step that mixes all five.
Parallel branches
Parallel branches reduce latency when work is independent. Reserve budget before launching them and cap concurrency at the worker or tenant boundary.
Choose a join rule explicitly: require all branches, accept a subset, or continue with a typed partial result. A slow branch must not silently expand the overall deadline.
First-success race
A first-success race is useful for redundant reads or replaceable model routes. The first valid result wins and losing branches are cancelled.
Do not race non-idempotent writes. Cancellation cannot retract an external effect that already reached its destination.
Add an Agent step
Build Agents first, wrap them in Arc, and grant each step only the capabilities
it needs. Stable step IDs such as plan and write become durable identity, so
do not rename them while old checkpoints may resume.
let workflow = Workflow::builder("plan-and-write")
.version(1)
.agent("plan", planner, CapabilitySet::new())
.agent("write", writer, CapabilitySet::new())
.build()?;
let outcome = workflow.run("Design a retry policy", &run).await?;
println!("{}", outcome.output);Use a regular .step(...) for deterministic Rust logic and .agent(...) only
where model judgment is required. This keeps validation, writes, approvals, and
state transitions outside prompts.
Add review-gated generation
Use .repairable_agent(...) when an application-owned reviewer must approve a
generated value before the workflow commits it:
let workflow = Workflow::builder("reviewed-answer")
.repairable_agent(
"draft",
answer_agent,
compliance_reviewer,
WorkflowRemediationPolicy::new(2),
generation_capabilities,
reviewer_capabilities,
)
.build()?;The first generation receives ordinary workflow input. A repair verdict persists the rejected candidate and structured feedback before the next generation. Generation, review, approval and repair are separate write-ahead substages, so recovery continues from a durable candidate without generating it again. Draining 0.7 workers before a 0.8/0.9 checkpoint writer is mandatory: schema v5 can be read by 0.9 workers but not by 0.7 workers.
Choose the execution pattern
| Requirement | Builder shape | Important constraint |
|---|---|---|
| every stage depends on the previous result | chained step / agent calls | keep each output serializable |
| all independent results are required | parallel | reserve a bounded budget for every branch |
| the first valid read is enough | race | only Pure or ReadOnly capabilities |
| work must survive process loss | durable store + worker | version definitions and stable IDs |
| a person or timer must resume work | durable wait / signal | authenticate and deduplicate signals |
Debug a failed workflow
- Build the workflow at startup and fail immediately on duplicate or invalid IDs.
- Log the workflow name, definition version, step ID, Run ID, and typed error kind.
- Inspect
outcome.usageand the Run journal before assuming the provider failed. - If an external write may have completed, reconcile it; do not blindly rerun.
- For durable execution, verify the deployed worker still registers the checkpoint's exact workflow version.
Continue with durable workflows when a run must survive restart, parallelism for fan-out/race semantics, and workers for queue and lease operation.