Flows
A flow is the program around tasks. It calls one, then another, fans several out, sleeps for
a week, waits for a person, and survives every process restart along the way. A run of a flow is
addressed exactly like a run of a task: by RunId, through a handle.
object AgentRun : FlowDefinition<AgentInput, AgentOutput> {
override val type = flowType<AgentInput, AgentOutput>("AgentRun")
override fun execute(input: AgentInput, flow: FlowScope): AgentOutput {
val history = mutableListOf<String>()
val approvals = flow.signals<Approval>("approval")
repeat(input.maxSteps) {
val plan = flow.task(PlanStep.type, PlanInput(input.goal, history), planning)
if (plan.tool == null) return AgentOutput(plan.answer, history)
if (plan.needsApproval) {
val approval = approvals.receive(30.minutes)
if (approval?.approved != true) return@repeat
}
history += flow.task(RunTool.type, ToolInput(plan.tool, plan.args), tooling).observation
}
return AgentOutput("Stopped after ${input.maxSteps} steps.", history)
}
}
What a flow body may do
Everything goes through the FlowScope it is handed:
| Call | Meaning |
|---|---|
flow.task(type, input, options) | run a task and wait for it |
flow.startTask(...) then .await() | fan out, then collect |
flow.sleep(duration) | a durable timer; the process can die and the timer still fires |
flow.signals<T>("name") | the signals sent to this run under that name, buffered from the start |
flow.awaitUntil(timeout) { ... } | block until a condition holds |
flow.now | engine time, replay-safe |
The one rule
The body is deterministic and it is not suspend. Given the same input and the same
answers from the engine (task results, signals, time), it makes the same calls in the same order.
No clock, no random, no I/O, no threads. Everything with a side effect is a task.
Why: after a crash the engine re-runs the body from the top, feeding it the recorded answers, until it reaches the first call without an answer yet. A body that consults the wall clock breaks that replay. Determinism has the details.
Failure
- A task that fails inside a flow surfaces as
TaskFailed. Catch it when a fallback exists; the "tool failed, try another way" shape of an agent loop. - A body that throws fails the run, with the exception's message. It is retried only if the run's
RetryPolicysays so, and for flows that should almost always beRetryPolicy.NONE: a flow that fails usually has a bug, not a blip. - Cancellation arrives as an exception thrown from a blocking scope call. Never catch
Throwablearound a scope call.
Remember: the flow is the loop and the waiting; the LLM call and the tool call are tasks.