Loops

Some work only converges through repetition: revise, test, revise again, until the result is good enough or the effort stops paying off. A single codon cannot express that, because a codon runs once. Asking the agent itself to decide when it is "done" moves the stopping decision into a place where the runtime cannot check it, meter it, or show it to you.

A loop makes the repetition explicit instead. You declare the condition that ends the loop, and hankweave expands the next iteration only when the current one reaches its boundary. That gives you a fixed iteration ceiling or a runtime context boundary in place of an agent's undefined sense of "done." This page builds up how that works: why the stop condition belongs to the runtime, what a loop declares, how it decides to stop, what can interrupt it mid-iteration, what the validator enforces, and what a finished loop leaves behind.

Who decides when a loop stops?#

An agent can judge a task, so why declare a stop condition at all? Because two different decisions are involved, and they belong to different parties. The agent judges the work; the runtime decides whether to schedule more of it. Only the second decision can be validated, budgeted, and shown in the debugger.

A loop's terminateOn value is a schema-checked choice between a numeric iteration limit and a context-exhaustion signal. The runtime places only the first iteration in the plan; after its last codon completes, it checks the stop condition before deciding whether to inject another iteration. A fixed limit supplies a known ceiling, while a context boundary supplies a signal that cannot be predicted before the work fills the context.

The figure below shows that decision point. Only iteration 0 exists in the plan at the start; each later iteration is injected lazily, after the check passes.

FIG. 1 Why can't the agent decide when it's done?
Read the diagram as text
Output
iteration 0 is placed in the plan
        │
        ▼
last codon completes ──► check stop condition and runtime limits
        │                         │
        │ stop                    │ continue
        ▼                         ▼
next top-level item       inject iteration 1, then repeat
Output
# Pseudocode: lazy expansion
plan ← [iteration 0]
after the last codon of iteration N completes:
  check terminateOn and runtime limits
  if the loop is not done:
    inject iteration N + 1 after the current position
  otherwise:
    continue with the next top-level item

This is not a promise that every iteration will finish the work. It is a decision point at every boundary: the runtime can stop for the declared condition, budget exhaustion, or context overflow without planning work that has not run. The debugger therefore shows the work that has actually entered execution rather than a speculative expanded plan.

What a loop declares#

A loop is a hank array entry with type: "loop". Its required fields are id, name, terminateOn, and codons; description, budget, and archiveOnSuccess are optional. The schema strip below shows the supported field shapes and the required set.

Scroll to explore the table →
fieldtypedefaultrequiredconstraintsdescription
typestringyes= loopType discriminator - required for loops
idstringyesminLength 1Unique identifier for this loop
namestringyesminLength 1Human-readable name displayed in UI and logs
descriptionstringnoOptional description shown to users
terminateOniterationLimit | contextExceededyesTermination condition for the loop
codonsarray<object>yesminItems 1Array of codons to execute in each iteration
budgetobjectnoBudget scope for this loop.
archiveOnSuccessarray<string>noPaths to archive when the loop terminates. Paths are relative to the agent workspace.

The body is a non-empty array of codon objects. Nested loops are not accepted by the loop schema. Each codon instance receives a runtime ID such as append-a#0; the iteration number is zero-based. The execution entry also carries loopContext with the loop ID, the zero-based iteration, and the codon's zero-based position within the loop.

To see these fields in combination, here is a minimal fixture with two codons and an iterationLimit of 2. It validates with exit status 0.

JSON
{
  "$schema": "https://unpkg.com/hankweave@0.11.0/schemas/hank.schema.json",
  "meta": {
    "name": "Minimal loop",
    "version": "1.0.0",
    "description": "Two haiku codons inside one loop bounded by iterationLimit 2."
  },
  "hank": [
    {
      "type": "loop",
      "id": "append-loop",
      "name": "Append loop",
      "terminateOn": {
        "type": "iterationLimit",
        "limit": 2
      },
      "codons": [
        {
          "id": "append-a",
          "name": "Codon A appends",
          "model": "haiku",
          "continuationMode": "fresh",
          "promptFile": "./prompts/append-a.md",
          "checkpointedFiles": [
            "log.txt"
          ]
        },
        {
          "id": "append-b",
          "name": "Codon B appends",
          "model": "haiku",
          "continuationMode": "fresh",
          "promptFile": "./prompts/append-b.md",
          "checkpointedFiles": [
            "log.txt"
          ],
          "outputFiles": [
            {
              "copy": [
                "log.txt"
              ]
            }
          ]
        }
      ]
    }
  ]
}

A loop iterates until it terminates. For the mechanisms used by its body codons, including continuation, retry, sentinels, checkpoints, output files, and codon-level budgets, see codon concepts and the hank JSON reference.

Validating this fixture produces the plan and run captures below. Read the plan first: append-loop has two iterations, each containing append-a followed by append-b. The run capture then shows the corresponding runtime IDs, from append-a#0 and append-b#0 through append-a#1 and append-b#1, with a loop.iteration.completed event after each iteration. The banner in the validate excerpt names the runtime that took the capture.

Output

╭────────────────────────────────────────────────────────────────────╮
│  Hankweave v0.11.0                                                 │
│  linux x64 • node v22.23.2                                         │
╰────────────────────────────────────────────────────────────────────╯

Calculating data signature for validation...

> Validating configuration: <workspace>/fixtures/scenarios/loops/hank.json

  Data source:    <workspace>/fixtures/scenarios/loops/data
  Execution path: ~/.hankweave-executions/validation-<id>

✓ Configuration is valid!

╭──────────────────────────────────────────────────────────────────────────────╮
│  Minimal loop v1.0.0                                                         │
│  2 codons • 1 loop                                                           │
╰──────────────────────────────────────────────────────────────────────────────╯

└─ [1] LOOP: append-loop (Append loop) × 2 iterations
  ╭────────────────────────────────────────────────────────────
  │
  ├─ [1.1] append-a (Codon A appends)
  │     model: haiku │ mode: fresh │ prompts: 1 (2 lines)
  │     checkpointedGlobs: 1
  │     ↓
  └─ [1.2] append-b (Codon B appends)
        model: haiku │ mode: fresh │ prompts: 1 (2 lines)
        checkpointedGlobs: 1
  │
  ╰────────────────────────────────────────────────────────────

╭─ GOOD TO RUN! ─────────────────────────────────────────────────────╮
│  2 codons • 2 prompts • 0 system prompts • 0 rigs • 2 checkpoints  │
╰────────────────────────────────────────────────────────────────────╯

Run it:  hankweave hank.json <data_path>

Environment Variables:

  From System (HANKWEAVE_ prefixed):
    - CAPTURE_VERSION: 0.11.0

Warnings:
  - Loop 'append-loop' > Codon 1 (append-a): loop codon defaults to 'onFailure: abort'. A single transient failure (idle timeout, one-off provider error) on any iteration will halt the entire loop run. Consider 'onFailure: retry' (with retryConfig for bounded attempts) or 'onFailure: ignore' to keep the loop progressing across transient blips.
  - Loop 'append-loop' > Codon 2 (append-b): loop codon defaults to 'onFailure: abort'. A single transient failure (idle timeout, one-off provider error) on any iteration will halt the entire loop run. Consider 'onFailure: retry' (with retryConfig for bounded attempts) or 'onFailure: ignore' to keep the loop progressing across transient blips.
exit=0
Output
{"id":"<id>","timestamp":"<ts>","type":"codon.started","data":{"codonId":"append-a#0","codonName":"Codon A appends","sessionId":"<session>","startTime":"<ts>"}}
{"id":"<id>","timestamp":"<ts>","type":"codon.completed","data":{"codonId":"append-a#0","success":true,"cost":"<n>","duration":"<n>","exitStatus":{"type":"success"}}}
{"id":"<id>","timestamp":"<ts>","type":"codon.started","data":{"codonId":"append-b#0","codonName":"Codon B appends","sessionId":"<session>","startTime":"<ts>"}}
{"id":"<id>","timestamp":"<ts>","type":"codon.completed","data":{"codonId":"append-b#0","success":true,"cost":"<n>","duration":"<n>","exitStatus":{"type":"success"}}}
{"id":"<id>","timestamp":"<ts>","type":"loop.iteration.completed","data":{"loopId":"append-loop","iteration":0,"durationMs":"<n>","costUsd":"<n>","tokensUsed":"<n>","isFinal":false}}
{"id":"<id>","timestamp":"<ts>","type":"codon.started","data":{"codonId":"append-a#1","codonName":"Codon A appends","sessionId":"<session>","startTime":"<ts>"}}
{"id":"<id>","timestamp":"<ts>","type":"codon.completed","data":{"codonId":"append-a#1","success":true,"cost":"<n>","duration":"<n>","exitStatus":{"type":"success"}}}
{"id":"<id>","timestamp":"<ts>","type":"codon.started","data":{"codonId":"append-b#1","codonName":"Codon B appends","sessionId":"<session>","startTime":"<ts>"}}
{"id":"<id>","timestamp":"<ts>","type":"codon.completed","data":{"codonId":"append-b#1","success":true,"cost":"<n>","duration":"<n>","exitStatus":{"type":"success"}}}
{"id":"<id>","timestamp":"<ts>","type":"loop.iteration.completed","data":{"loopId":"append-loop","iteration":1,"durationMs":"<n>","costUsd":"<n>","tokensUsed":"<n>","isFinal":true,"terminationReason":"iteration_limit"}}

How a loop decides to stop#

Every loop chooses exactly one terminateOn variant. The two variants and their constraints are shown below.

Scroll to explore the table →
fieldtypedefaultrequiredconstraintsdescription
iterationLimitobjecttype = iterationLimit
↳ limitintegeryesmin 1
contextExceededobjecttype = contextExceeded

With iterationLimit, the limit is checked after the last codon of an iteration completes. A limit of 1 runs iteration 0 once; a limit of 3 runs iterations 0, 1, and 2. With contextExceeded, the loop continues until the runtime reports context exhaustion; it is not a precomputed iteration count. A loop budget or a loop-body codon's own cap can end a contextExceeded loop before its signal arrives (see the next section).

Context exhaustion is a successful loop termination. The loop is marked complete and execution continues with the next top-level item, but the context remains exhausted, so a following top-level codon cannot use continue-previous.

Compaction changes which signal supplies context exhaustion. With autoCompact: false (the default), the provider's context-overflow error is the signal. With autoCompact: true, the harness writes a compact_boundary marker into its session log, compacts the session, and continues on the compacted session until that boundary is reached. The marker is a harness session-log message, not a run event: it never reaches the event journal or the WebSocket.

What happens when the money runs out#

The declared stop condition is not the only thing that can end a loop. Budget exhaustion is a separate early-stop mechanism, not a third terminateOn variant. A loop ends early when the finishing loop-body codon's own budget was exceeded or the loop's own budget was exceeded, and that preempts whatever terminateOn declares. The planner removes the remaining codons in the current iteration at the over-budget codon (lazy expansion means no later iteration is in the plan yet), and execution continues past the loop only when that codon ends completed (the default onExceeded: "complete") or fails under onFailure: "ignore". With onExceeded: "fail" under the default onFailure: "abort", the codon fails non-retriably and the run shuts down instead of continuing past the loop.

Dollar usage is metered as assistant messages arrive: each billable message is reported, and when accumulated cost reaches maxDollars the budget tracker fires the exceeded state and the runtime requests the codon's interruption (SIGTERM). The interrupt can land after an already-billable message. The codon's onExceeded policy then records it completed (the default) or failed. Time caps are enforced actively: whenever a codon or loop time limit is set, a one-second watchdog checks elapsed time, and the same check runs on every cost update. The exceeded state surfaces on codon.completed.budgetExceeded (currency, limit, used); it never appears as a loop.iteration.completed.terminationReason.

A loop budget uses the same container shape as a hank-level budget override:

Scroll to explore the table →
fieldtypedefaultrequiredconstraintsdescription
maxDollarsnumberno> 0Total cost budget in USD.
maxTimeSecondsnumberno> 0Wall-clock time limit in seconds.
allocationenumsharednoshared | proportional | proportional-strictHow the dollar budget is distributed among children. 'shared': first-past-the-post (default). 'proportional': pre-allocate shares, unspent flows back. 'proportional-strict': pre-allocate shares, unspent evaporates.
sharesobject<number>noMap of child codon/loop ID to fraction (0-1) of the budget.
onExceededenumnocomplete | failWhat happens when budget is exceeded. 'complete' = graceful completion (default). 'fail' = codon failure, triggers onFailure policy.

For allocation behavior, see budgets. Its complete and fail handling is part of that budget contract; do not infer a loop event termination reason from the onExceeded value.

When a loop can stop mid-iteration#

The stop mechanisms described so far fire at different moments, and the order matters. The runtime checks termination in this order: budget exhaustion can fire at any codon, and context exhaustion can fire at any codon in a contextExceeded loop, removing the rest of the iteration; an iterationLimit check runs only at the iteration's last codon; a contextExceeded loop keeps going until its signal arrives. This is why a loop can stop before all of its declared body has run.

In a contextExceeded loop, if context fills during a multi-codon iteration, the current codon completes with contextExceeded, the remaining codons in that iteration are removed, the loop terminates successfully, and execution continues after the loop. The same removal rule applies when a budget is exceeded.

Within an iteration, codons run in array order. With [A, B] and limit: 2, the runtime order is A#0, B#0, A#1, B#1.

Rules the validator enforces before you run#

Several loop constraints are checked at load time rather than discovered mid-run:

  • A contextExceeded loop requires every body codon to use continuationMode: "continue-previous". A fresh codon resets context between iterations, so the validator rejects that combination at load time.
  • Consecutive continue-previous codons inside a loop must use the same model because different models cannot share one session ID; this is also a load-time error.
  • The default onFailure: "abort" on a loop codon produces a validation warning. A transient failure on one iteration can halt the loop; choose retry or ignore when that is the intended policy.
  • A loop codon's rigSetup operations produce a validation warning unless they set allowFailure: true. Setup that creates a resource can succeed on iteration 0 and fail on a later iteration.
  • continue-previous after a loop checks the loop's last codon: a model mismatch is an error, and missing checkpointed files produces a warning.
  • continue-previous after a codon with exhaustWithPrompt, or after a loop whose last codon has it, is a load-time error because there is no remaining context to continue.

What a loop leaves behind#

A loop's own archiveOnSuccess moves matched files to rigArchive/<loopId>-loop/<path>. It runs each time a completed or skipped loop codon does not add codons to the plan: at the real termination, and also after every codon of an iteration except its last. That makes it run once only for a single-codon body; in a multi-codon body, later codons in the iteration find the files already moved to the loop archive.

A codon's archiveOnSuccess inside a loop fires after that codon completes successfully and before loop expansion, so on a multi-codon body a non-last archive fires before the iteration's remaining codons run. Files move to rigArchive/<loopId>-<iteration>/<runtime-codon-id>/<path>, where <runtime-codon-id> is the codon's runtime ID <codonId>#<iteration> with # replaced by -, and <path> is the matched file's path relative to agentRoot/. The iteration appears once in each segment: edit-0, never edit-0-0 and never edit. Archive entries are file globs resolved to files only, so use current-project/** to archive a tree rather than the bare directory name.

The tree below comes from a real fixture and shows the resulting archive directories: revise-0/edit-0 and revise-1/edit-1.

Captured: 2026-09-04 · runtime: hankweave@0.10.0

Output
rigArchive/revise-0/edit-0/current-project/history.txt
rigArchive/revise-1/edit-1/current-project/history.txt

From an agentRoot command, the archive is the sibling path ../rigArchive/, not a directory inside the agent root. Archive path and manifest details belong to execution-directory reference.

Sentinels declared on loop codons load fresh for each iteration with a clean history; the previous codon's sentinels are unloaded and their internal state resets. Their output paths do not all accumulate: a continuous log accumulates across iterations only when the instances reuse the same resolved path, while auto-generated log paths (which include the runtime codon ID and a timestamp) differ per iteration. The last-value file is atomically replaced with the latest output each time, for both structured and text output.

Loops also leave a record in the event stream. The loop.iteration.completed event is journaled in the server-state category and routed through sentinels. Its payload includes the loop ID, zero-based iteration, duration, cost, token count, isFinal, and an optional termination reason.

Scroll to explore the table →
idcategoryjournaledsentinelRoutedpayloadFieldsreceipts
loop.iteration.completedserver-statetruetrueloopId, iteration, durationMs, costUsd, tokensUsed, isFinal, terminationReason?schemas/event-schemas.ts:724, schemas/event-schemas.ts:1090, schemas/event-schemas.ts:1359, hankweave-runtime.ts:6415

In the captured run from the earlier fixture, the first event uses iteration: 0 with isFinal: false, followed by iteration: 1 with isFinal: true and terminationReason: "iteration_limit". The termination reason is optional: the runtime sets it only on a loop's final iteration, and only to context_exceeded or iteration_limit. sentinel_skip and failure are allowed by the schema enum but never sent. A loop ended by a budget emits no termination reason at all.

How to think about loop design#

continuationMode is a required field you write on every codon; there is no default. Use continue-previous when iterations should accumulate context and build on earlier work. fresh is an explicit choice that resets the agent's memory each iteration; it is legal in an iterationLimit loop and rejected in a contextExceeded loop.

Long continue-previous chains in an iterationLimit loop can reach the context window mid-run. With the default autoCompact: false, the iteration that reaches the boundary fails; set autoCompact: true when the loop is expected to cross that window.

Try write → test → review, fill context then summarize, or clean the workspace per iteration, depending on what the next agent needs. We work through the handoffs in the BOUNDARY DRILL.