Workflows
A workflow is an async Python function decorated with@workflow. It receives a context and a set
of named inputs, and it returns a result. Its inputs and result are arrays, collections of
values indexed by named dimensions, which
Arrays and parallel computations covers in
depth. Inside the workflow you mostly do not touch the arrays’ data. You work with
handles to them, and you pass those handles between the operations you schedule.
Here is a workflow that scores a set of prompts with a model, then asks a child workflow to
summarize the scores:
context.run_step(name, step, inputs, ...)schedules a step and immediately returns a handle to its future result. It does not wait. Themap_overargument says which dimensions to run the step once per key of, soansweris called once for every model and prompt pair.- Passing the
answershandle to thescorestep is what makesscoredepend onanswer. fxtr draws an edge between them in the viewer, and will not startscoreuntil the answers exist. context.run_child_workflow(...)works the same way for a workflow. Any workflow can be the root of a job, and any workflow can be a child of another, so you can compose an experiment out of reusable parts.
Determinism
fxtr runs a workflow body more than once. It replays the body to resume a job after a crash, and by default it also runs the body a second time at the end of every attempt to verify that the body made the same decisions. A workflow must therefore be cheap and deterministic:- Do anything expensive, random, or dependent on the outside world in a step.
- Never generate random numbers, UUIDs, or timestamps in a workflow.
- Give every scheduled operation a stable name. Names are how fxtr matches a replayed operation with the recorded one.
- Fix datasets and settings before the job starts and pass them in as inputs.
DeterminismError rather than silently continuing with a mismatched record.
Workflow determinism is only required within a single job. If you change your workflow code, you can simply start a new job to execute it from scratch. The step cache is shared across jobs, so this still allows you to re-use expensive work.
Handles
A handle (ArrayHandle) is a reference to an array that may not have been computed yet. Its
type, the dimensions and the kind of value it holds, is known the moment you receive it, because
fxtr derives the type of every operation from the types of its inputs. That is why the workflow
above can pass answers to the next step before any model has been called: the type checks
happen while the graph is being built, and a mismatch is an error at scheduling time rather than
after an hour of sampling.
You can also ask a handle for its data, with await handle.observe() for the whole array or
await handle.item() for a single value. This pauses the workflow until the result exists, and it
is how a workflow makes a data-dependent decision (see
Data-dependent control flow).
Mapping a step over a handle’s dimensions, and the other ways of combining handles, are covered in
Arrays and parallel computations.
Steps
A step is where the real work happens. It is an async function decorated with@step that
receives concrete data, computes something, and returns a result. Model calls, LLM judges,
simulations, external API queries, random sampling, and heavy numerical work all belong in steps.
So does any deterministic transformation whose result you want cached or whose place in the graph
you want to see in the viewer.
Here is a step that asks a language model one question, using the
behaviors library:
ModelConfig and Answer are entity types the project defines (see
Entities and custom types), and answer_text is a
small helper that joins the text blocks of the reply. The details of building requests, holding
multi-turn conversations, and judging outputs are in
Calling language models.
How steps run and get cached
To run a step, schedule it from a workflow withcontext.run_step. Scheduling gives the
invocation an address in the job and records what inputs it received, and it is what connects
the step to the cache, to the viewer, and to the job’s record. (Don’t call the step function
directly from your own code, this will not be tracked by fxtr!)
Before running a step, fxtr looks at that address in the project’s step cache. If a result
computed by the same step from the same inputs is already there, the job simply
reuses the result. If there is no result there, the step runs and its result is stored at the address. This is what
lets you edit a workflow, launch it again, and pay only for the new work. When the cache holds a result computed
from different inputs, the job either pauses for review or overwrites the cache, depending on the configuration.
Managing the step cache explains this in more detail.
Failures: return or raise?
A step that raises an exception is treated as having hit a transient problem. Its attempt fails, nothing is cached, work that does not depend on it keeps going, and the job ends without succeeding. When you resume the job, the step runs again. This is the right behavior for a network error, a rate limit that outlasted the retries, or a crashed subprocess. A step that returns a value is treated as having succeeded, and its result is cached like any other, even if that result records an error. So the rule is:- Return for an outcome every attempt would reproduce: an input with nothing to grade, a reply that did not contain the expected answer, a conversation that hit the context limit. Represent the outcome in your result type, for example with a nullable score or an error field.
- Raise for a failure a later attempt might not hit.
Caching does not make side effects happen exactly once. An attempt that is interrupted after it
sent a request but before its result was saved will run again when the job resumes.
Steps should be pure, even if nondeterministic
A step may be nondeterministic. Two samples from the same model with the same prompt differ, and that is fine, because fxtr records whichever one the step returned and never recomputes it. But a step should still be functionally pure: its result should depend only on its inputs, plus the external services it calls through stateless APIs. It should not read files from the machine it happens to run on, consult environment-specific state, or write anything anywhere except through its return value and the entities it stores. The reasons are spelled out in Hermeticity below.Contexts and signatures
Signatures
Every step and workflow has a signature: the array type of each input and of the result. fxtr reads it from the function’s type annotations. A few conventions cover most cases:
The string inside
Annotated names the dimensions, which no Python type can express on its own.
Array[T] or ArrayHandle[T] alone says what the values are but not what the dimensions are, so
always wrap one in Annotated[..., "[...]"], including "[]" for a scalar.
For an input that is a reference to an entity, a
stored record with an ID of its own, the annotation also decides what the body receives:
config: ModelConfigloads the entity before the body runs. This is the common case.config: BoundID[ModelConfig]passes a typed reference without loading it, which is what you want when the step’s result should point back at the input.Annotated[Array[BoundID[Document]], "[doc: str]"]passes a whole array of references.
-> Answer above, can return the entity itself.
fxtr stores it and records its reference as the step’s result. A step whose result has dimensions
returns an Array.
The context argument
The first parameter of every step and workflow is its context, and it must be namedcontext. A StepContext and a WorkflowContext both let you:
- Store and load entities.
ref = await context.store(entity)returns a reference, andawait context.load(ref)turns one back into the object. Entities stored this way become part of the job’s record. - Log.
context.log("scored 40 of 100")writes a line to the attempt’s log, which the viewer shows alongside the invocation.
WorkflowContext additionally has the scheduling operations: run_step,
run_child_workflow, add_source_array for introducing fixed data into the graph, and combine
and stack for putting handles side by side. A StepContext exposes the step’s cache_key, the
address its result is stored at.
Data-dependent control flow
Because a workflow is ordinary Python, it can look at a result and decide what to schedule next. Observing a handle waits for its value, so the workflow pauses at that point, then continues building the graph with the value in hand. The classic shape is a refinement loop that continues until a judge is satisfied:Hermeticity and handling external state
Everything inside a workflow function or a step function should be hermetic: it should be possible to run it in a sandbox outside your environment, on a different machine, and get an equivalent result. The reason is that fxtr’s record of a job is meant to be a complete account of what was computed. It stores the inputs every step received, the code commit the job ran at, and every result. If a step quietly read a file from your laptop, that file is not in the record, the step’s cache entry cannot be trusted (the address and fingerprint say nothing about the file’s contents), and the job cannot be resumed by anyone who lacks the file. Concretely, inside steps and workflows:- Don’t read local files. No
open(...), no paths relative to the project, no datasets loaded from disk. The launcher is the only place that reads files. - Don’t depend on the machine. No environment variables that change behavior, no hostnames, no reliance on what else is installed. Credentials for model providers are the one accepted exception: they live in the environment, never in inputs or entities, and whoever runs the job must supply them.
- Call only stateless services. Model APIs and similar request-response services are fine. A service whose answer depends on state your step changed earlier is not, because a replay would see different state.
- Don’t write anywhere except the record. A step’s output is its return value and the entities it stores. Writing files or updating external databases from a step leaves things behind that the record does not know about.