Skip to main content

Flows

View Markdown

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:

CallMeaning
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.nowengine 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 RetryPolicy says so, and for flows that should almost always be RetryPolicy.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 Throwable around a scope call.

Remember: the flow is the loop and the waiting; the LLM call and the tool call are tasks.