Agents as durable workflows
How Flux models agents — what's the same as a normal workflow, what's different, and when to use the in-workflow agent() versus the YAML harness.
An agent in Flux is a workflow whose orchestrator is an LLM. The workflow function is short — usually a loop. The tasks are tools the LLM can call. Replay, retries, idempotency, and observability come from the workflow/task split you already know.
This page lays out the mental model: how Flux’s two agent surfaces map onto the same primitives, what is the same as any other workflow, and what is genuinely different about driving control flow with a model instead of with Python.
Two surfaces, one model
Flux exposes agents two ways. They look different from the outside but they share the same internals.
In-workflow agent(). A factory imported from flux.tasks.ai. You await it inside a @workflow function and you get back a Flux @task you can call with an instruction:
from flux import ExecutionContext, workflow
from flux.tasks.ai import agent
@workflow
async def answer(ctx: ExecutionContext):
assistant = await agent(
"You are a concise assistant.",
model="anthropic/claude-sonnet-4-20250514",
tools=[search_web, read_file],
)
return await assistant(ctx.input["question"])
The agent() call doesn’t talk to the LLM. It builds a @task wrapper around a provider client and a tool dispatcher and returns it. The actual model invocation happens when you await assistant(...). Inside that call, the loop in flux/tasks/ai/agent_loop.py runs: LLM call, tool dispatch, LLM call, tool dispatch, until the model emits a final response or hits max_tool_calls. Each LLM call is itself a Flux task (named llm_0, llm_1, …). Each tool call is the @task you supplied. From the workflow’s point of view it’s one await over one task; from the event log’s point of view it’s a tree.
Standalone YAML harness. A long-running process you start from the CLI. The agent definition is YAML:
name: my-assistant
model: ollama/qwen3:8b
system_prompt: |
You are a helpful assistant.
tools:
- files:
workspace: .
max_tool_calls: 10
flux agent create my-assistant --file assistant.yaml
flux agent start my-assistant --mode terminal
The harness looks like a chat process — type, get a reply, type again. But each turn the harness submits an execution of the built-in workflow agents/agent_chat (in flux/agents/template.py). That workflow reads the YAML definition from the configs store, builds an agent(), and runs one turn. The conversation REPL is outside the workflow model, but the individual turns go through the same agent() factory and the same agent_loop.py as the in-workflow surface.
Don’t confuse the two. They’re for different jobs.
| Surface | What it is | When to use |
|---|---|---|
In-workflow agent() | A @task you call inside a @workflow | The agent is one step in a larger workflow — research → summarize → email, or “classify, then route.” |
| YAML harness | A standalone process with flux agent start | A standalone agent serving a chat UI, an MCP endpoint, an HTTP API, or a long-running session. |
Both produce identical event logs for the LLM-and-tool portion. The difference is where the outer loop lives: in your workflow body for agent(), or in the harness process for the YAML form.
What’s the same as a workflow
Once a turn starts, an agent is indistinguishable from any other Flux workflow. The same machinery applies.
Durability. Every LLM call and every tool call is an ExecutionEvent in the log. If the worker dies mid-turn, the next worker to claim the execution replays the recorded events forward — the LLM is not re-prompted for completed calls, and tools that already returned are not re-executed. The agent picks up at the next un-recorded step.
Retries. A tool is a @task. Add retry_max_attempts=3 and Flux retries on exception. The retry chain (retry → fallback → rollback) applies to tool calls exactly as it does to ordinary tasks.
Idempotency by replay. A completed TASK_COMPLETED event is the source of truth. The LLM’s response to a tool result is also a TASK_COMPLETED event (for llm_N). So replay returns the same LLM response and the same tool result it returned originally. The conversation history the LLM sees on the second invocation is identical to the first.
Cancellation and pause. An agent can pause mid-conversation — either because a tool requested approval, or because the workflow called pause(). When it resumes, the recorded turns are replayed and the conversation continues from where it stopped. Cancellation aborts the execution like any other.
Observability. Each task in the loop emits an event and a span. The trace for one agent invocation looks like a tree of llm_0, tool_a, llm_1, tool_b, llm_2. You can see exactly which tool the model called, with what arguments, and what came back.
None of this is agent-specific code. It falls out of the workflow/task split that the Task/workflow split page covers — the agent is just a workflow whose control flow happens to be driven by an LLM.
What’s different from a workflow
A regular workflow’s plan lives in Python. You write if, for, await and the plan is fixed at the time the workflow function is parsed. Determinism requires the plan to re-evaluate the same way on every replay.
An agent’s plan lives in the model. The LLM decides which tool to call, in what order, with what arguments, and when to stop. That decision is non-deterministic in a way Python control flow isn’t — the same prompt and the same history can produce different tool calls on different runs, depending on sampling, model version, and provider weather.
Flux handles this by recording the LLM’s outputs, not regenerating them. The first time llm_0 runs, the model produces a response and Flux writes a TASK_COMPLETED event with the recorded text and tool calls. On replay, Flux returns the recorded response and the tool calls that the model emitted the first time. The model is not asked again. The plan that was non-deterministic on first run becomes deterministic on replay because it’s read from the log.
This is the only honest way to combine “the LLM picks the next step” with “replay produces the same execution.” If you re-prompted the model on replay, you’d risk a different tool call than the one Flux already executed, and the workflow would desync.
One consequence: an agent’s behavior across separate runs is variable. The same input might produce 3 tool calls on one run and 4 on another. The replay of a given run is exact, but two fresh runs of the same input are not guaranteed to match. This is intrinsic to LLM-driven control flow, not a Flux limitation.
The execution unit: a turn
A turn in an agent conversation is one LLM call, zero or more tool calls, and one final response. Concretely, the loop in agent_loop.py is:
- Call the LLM with the current message history. Record
llm_N. - If the response contains tool calls, dispatch them (sequentially, or up to
max_concurrent_toolsin parallel). Each dispatch is a@taskcall, recorded individually. - Append the tool results to the message history.
- Go back to step 1, with
Nincremented. - Stop when the LLM returns a response with no tool calls — or when
tool_call_count >= max_tool_calls, at which point Flux re-prompts the model without tools and forces a final answer.
A “turn” can be one LLM call (no tools needed) or many (the model planned and used five tools before answering). Either way the whole turn is one await assistant(...) from the workflow’s point of view, and every individual step inside is durable. If the worker crashes between step 2 and step 3, replay resumes at step 3 — the tool result is in the log, the LLM doesn’t run again, the next call starts with the same history it would have had.
Memory
The conversation isn’t held in process memory by default. With working_memory attached, every user message, assistant response, tool call, and tool result is recorded via WorkingMemory.memorize(...), which itself runs through a Flux task. The history is durable across crashes, across workflow pauses, and across the workflow→harness boundary — a YAML-harness session and an in-workflow agent() with the same memory backend see the same conversation.
long_term_memory is the persistent layer: facts the agent stores deliberately, surviving past the end of a session. Both layers are covered in detail in Memory architecture.
Sub-agents and trees of work
An agent can delegate to other agents. Pass agents=[other_agent_def, ...] to agent() and Flux injects a delegate tool the LLM can call; the delegated agent runs as a sub-workflow whose result is returned to the parent. The full tree of work — parent agent, child agents, grandchildren — is a tree of workflow executions, each with its own event log, each independently retryable and resumable. The delegation machinery lives in flux/tasks/ai/delegation.py; see Sub-agents and trees of work for the model.
What this gives the reader
If you’ve been writing agent code without durability — calling OpenAI’s API inside a while True loop, with try/except around the tool dispatch and a JSON file for conversation history — here is what the Flux model gives you:
- Crash recovery without re-running tools. The worker dies; the next worker replays the recorded LLM responses and tool results and picks up at the next unfinished step.
- Per-turn observability. You can see which tools the model used, in which order, with which arguments, on which call.
- Pause and resume. The agent can stop mid-conversation for human approval, for a long-running tool, or for a scheduled wake-up, and pick up later from the recorded state.
- Deploy agents as durable services. A YAML harness running behind an HTTP or MCP endpoint survives restarts, deploys, and worker evictions without losing the conversation.
What it costs
The same things workflows cost, plus one more.
Discipline. Tools are @task functions. They have to be idempotent under replay (a half-sent email and then a crash will be re-sent on resume — see Agents in workflows / Tool idempotency). Conversation state lives in working_memory, not in Python variables. The prompt-building code can’t have hidden side effects — anything that varies across replay desyncs the recording.
Infrastructure. You need a Flux server and at least one worker (or the inline path for development). A while True LLM loop runs anywhere; a durable agent needs a process to record events to.
Non-determinism across runs. Two fresh runs of the same agent on the same input may produce different tool-call sequences. This is intrinsic to LLM control flow. If you need byte-exact reproducibility across runs (not just replay within a run), pin model versions, set temperature to 0 where supported, and accept that providers can still drift.
When to use which surface
The agent() factory is the right answer when the agent is one step in a larger workflow. Examples: a research agent inside a content pipeline; a triage agent that classifies an incoming ticket and routes it; a planning agent whose output drives the rest of the workflow’s branches. The agent is a building block, not the whole product.
The YAML harness is the right answer when the agent is the product. Examples: a chat UI backed by an agent; an MCP server exposing the agent as a tool to other agents; an HTTP endpoint that takes a message and returns a reply; a long-running conversational session you want to be able to resume from a particular turn days later. The harness owns the conversation loop; you don’t have to write it.
Both forms share the same definition schema, the same memory model, the same tool model, and the same event log. You can prototype with agent() in a workflow and move to the harness when you’re ready to expose the agent as a service — the LLM-and-tool behavior is identical because the underlying code is identical.
What to remember
- An agent is a workflow whose orchestrator is an LLM. The workflow is the loop; the tasks are the tools; the LLM picks which task to call next.
- Flux has two agent surfaces:
agent()for in-workflow use, and the YAML harness for standalone services. They share the same internals. - Durability, retries, idempotency, pause/resume, and observability all carry over from the workflow/task split. You don’t have to add them.
- The LLM’s non-determinism is handled by recording its outputs and replaying them, not by regenerating them. Within a run, replay is exact. Across runs, behavior can vary.
- A turn is the unit: one LLM call, zero or more tool calls, one response. Every step inside a turn is durable.
- The cost is task discipline (idempotent tools, no hidden side effects) and the Flux server/worker infrastructure.
Where this shows up
- Memory architecture — working memory, long-term memory, dreaming, and how each layer is made durable.
- Sub-agents and trees of work — delegation and the tree-of-workflows view.
- The agent primitive — the
agent()function reference and the in-workflow surface. - Agents in workflows — replay semantics, tool idempotency, and what gets checkpointed.
- Your first agent — the YAML-harness tutorial.
- The execution model — the replay loop that makes all of this safe.