Loops
The loop block runs a set of inner blocks for multiple rounds. It is the primary mechanism for iterative patterns like writer-critic refinement, progressive summarization, or retry-until-success flows.
Basic loop
Section titled “Basic loop”A loop block references other blocks in the same workflow by ID. Each round executes them in sequence.
blocks: draft: type: linear soul_ref: writer review: type: gate soul_ref: critic eval_key: draft pass: done fail: draft refine: type: loop inner_block_refs: [draft, review] max_rounds: 3 break_on_exit: pass done: type: code code: | def main(data): return {"final": data.get("draft", "")} depends: refine
workflow: name: Iterative Refinement entry: refineThe loop runs draft then review each round. If review produces an exit handle of "pass", the loop breaks early. Otherwise it continues up to 3 rounds.
Loop block fields
Section titled “Loop block fields”| Field | Type | Default | Constraints | Description |
|---|---|---|---|---|
inner_block_refs | List[str] | required | min 1 item | Block IDs to execute each round, in order |
max_rounds | int | 5 | 1—50 | Maximum number of iterations |
break_condition | ConditionDef or ConditionGroupDef | none | Condition evaluated against the last inner block’s output | |
carry_context | CarryContextConfig | none | How to pass context between rounds | |
break_on_exit | str | none | Exit handle value that stops the loop | |
retry_on_exit | str | none | Exit handle value that restarts the current round |
Breaking out of a loop
Section titled “Breaking out of a loop”There are three ways to exit a loop early.
break_on_exit
Section titled “break_on_exit”Set break_on_exit to an exit handle string. After each inner block executes, the engine checks the block’s result. If the exit_handle matches, the loop stops immediately.
refine: type: loop inner_block_refs: [draft, review] max_rounds: 5 break_on_exit: passThis is the most common pattern — pair it with a gate block whose pass exit handle triggers the break.
break_condition
Section titled “break_condition”A condition evaluated against the last inner block’s output at the end of each round. Uses the same condition engine as output conditions.
refine: type: loop inner_block_refs: [draft, review] max_rounds: 5 break_condition: eval_key: verdict operator: equals value: approvedYou can also use a ConditionGroupDef with multiple conditions:
break_condition: combinator: and conditions: - eval_key: score operator: gte value: 8 - eval_key: verdict operator: equals value: approvedretry_on_exit
Section titled “retry_on_exit”Set retry_on_exit to an exit handle string. When a block’s exit handle matches, the loop restarts the current round from the first inner block instead of advancing. This skips the break condition check for that round.
refine: type: loop inner_block_refs: [draft, review] max_rounds: 5 retry_on_exit: needs_revision break_on_exit: approvedCarrying context between rounds
Section titled “Carrying context between rounds”By default, each round starts fresh — inner blocks do not see the output of previous rounds. The carry_context configuration changes this by injecting prior round outputs into shared_memory.
refine: type: loop inner_block_refs: [draft, review] max_rounds: 3 carry_context: enabled: true mode: last inject_as: previous_feedbackCarryContextConfig fields
Section titled “CarryContextConfig fields”| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable context carrying |
mode | "last" or "all" | "last" | "last": inject only the previous round’s outputs. "all": inject an accumulating list of all rounds. |
source_blocks | List[str] | none | Specific inner blocks whose outputs are carried between rounds. If omitted, all inner blocks are used. Must be a subset of inner_block_refs. |
inject_as | str | "previous_round_context" | Key name in shared_memory where the carried context is stored |
Mode: last
Section titled “Mode: last”Injects a dict mapping source block IDs to their outputs from the previous round:
{"draft": "The revised paragraph...", "review": "PASS"}Mode: all
Section titled “Mode: all”Injects a list of all rounds’ outputs, oldest first:
[ {"draft": "First attempt...", "review": "FAIL: too short"}, {"draft": "Revised version...", "review": "PASS"}]When using mode: all, the engine applies budget-aware truncation to prevent context from growing unbounded. Older entries are pruned first when the carried context exceeds 3% of the model’s context window.
Injecting into task context
Section titled “Injecting into task context”When carry_context is enabled and the workflow has a current task, the engine also injects the carried context into task.context as elastic data. This means inner blocks that call fit_to_budget can access previous round outputs without explicit prompt engineering.
Stateful inner blocks
Section titled “Stateful inner blocks”Setting stateful: true on inner blocks enables conversation history persistence across loop rounds. The soul remembers what it said in previous rounds, which is useful for iterative refinement where the model should build on its own prior attempts.
blocks: draft: type: linear soul_ref: writer stateful: true # remembers prior drafts across rounds review: type: gate soul_ref: critic eval_key: draft pass: done fail: draft refine: type: loop inner_block_refs: [draft, review] max_rounds: 3 break_on_exit: passLoop metadata
Section titled “Loop metadata”After the loop completes, the engine stores metadata in shared_memory under the key __loop__{block_id}:
{ "rounds_completed": 2, "broke_early": true, "break_reason": "exit_handle 'pass' matched break_on_exit"}The break_reason values are:
"exit_handle '{handle}' matched break_on_exit"— broke viabreak_on_exit"condition met"— broke viabreak_condition"max_rounds reached"— ran all rounds without breaking
The loop also stores the current round number during execution at shared_memory["{block_id}_round"], so inner blocks can access it.
Loop with all block types
Section titled “Loop with all block types”Loop blocks work with any block type as inner blocks, including other loops, workflow blocks, gate blocks, and code blocks. The unified block execution lifecycle (execute_block) handles dispatch for all types inside the loop.
blocks: generate: type: code code: | def main(data): round_num = data.get("improve_loop_round", 1) return {"draft": f"Attempt {round_num}"} check: type: gate soul_ref: quality_checker eval_key: generate pass: done fail: generate improve_loop: type: loop inner_block_refs: [generate, check] max_rounds: 5 break_on_exit: pass