Closed-loop feedback

Iterate an automated step until a reviewer signs off, with explicit caps and stall detection.

Use this when an automated step should keep iterating until a separate reviewer node approves the result: the canonical "AI judge sends a draft back for another pass until it's good enough" shape. Roscoe treats cycles as a first-class pattern. The runner enforces a per-loop iteration cap and can short-circuit a stuck loop when consecutive iterations produce identical results.

The workflow#

A two-node loop where a draft step produces work and a review judge gates the exit. The back-edge from review re-enters draft when the verdict is negative; maxIterations on draft bounds the loop so it can't run forever.

name: reply-revision-loop
version: 1
description: Draft a customer reply and revise it until an AI reviewer signs off, or fail at the cap.
initial: draft
inputs:
  - name: email
    type: string
    description: The customer's email needing a reply.
states:
  draft:
    type: ai_agent
    label: Draft the customer reply
    model: claude-sonnet-5
    prompt: |
      Draft a reply to this customer email. Incorporate the reviewer's latest
      feedback: {{ review.reasoning || "" }}

      Customer email: {{ email }}
    maxIterations: 5
    on:
      done: review

  review:
    type: ai_judge
    label: Review the reply
    model: claude-sonnet-5
    prompt: |
      Does this reply meet our tone and policy guidelines? Reply true (send)
      or false (revise), and include reasoning the next draft can act on.
    validator:
      kind: boolean
    detectStall: true
    on:
      'true': approved
      'false': draft

  approved:
    type: end
    label: Approved
    outcome: success

The || "" fallback in draft's prompt matters on the first pass: review hasn't run yet at that point, and the loader rejects a template reference to a node that isn't guaranteed to have run before the one reading it. A fallback satisfies that check and reads as an empty string until the first verdict exists.

What happens at runtime:

  1. draft runs, produces a reply. done routes to review.
  2. review returns false (needs more work). The false transition is a back-edge: it points to a node already on the current path. The runner re-enters draft and increments its visit counter.
  3. The loop runs up to maxIterations: 5 attempts. If the 6th entry into draft is required, the run fails with a structured error pointing at the gate. If review returns the same verdict three times in a row, detectStall: true short-circuits the loop even earlier.

The cap#

Set maxIterations: N on at least one node in any automated cycle. The cap can sit on the draft node (as above), on the reviewer, or on a different node in a larger cycle: every iteration of the loop must pass through it, so the count is correct regardless of where you put it. Common choice: put it on the node whose retries cost the most (the draft node, usually).

Limits: integer between 1 and 50. Without a cap, the loader rejects the workflow with closed-loop cycle (...) has no maxIterations on any participating node. One exception: a cycle containing a human node is exempt, because every iteration requires an explicit user action (approve, retry, abort), and the human's pace is already a practical cap. Self-loops still need one; they're the simplest case and the easiest to forget.

Stall detection#

detectStall: true on a gate node turns on output fingerprinting. Each visit, the runner records a normalised digest of the node's output and chosen transition. Three identical digests in a row → the run fails as stalled before the cap fires. Useful when an AI judge gets stuck in a "reject because still bad" rut where each iteration looks the same.

Fingerprinting is whitespace-collapsed, case-insensitive, and order- independent for JSON objects. A judge that rejects with literally identical reasoning three times stalls; a judge that rejects with new specifics each time keeps going up to the cap.

detectStall is available on every node type that also takes maxIterations: script, ai_agent, ai_judge, consensus, round_robin, map, condition, and subworkflow. Most useful on reviewer-shaped nodes; not useful on a draft node whose output legitimately changes every pass.

What you see in the run view#

The web UI surfaces three affordances when a workflow contains a cycle. Loop-back edges render dashed, with a icon in the transition-label pill, so back-edges read distinctly from forward flow. The loop-target node shows a small loop badge: max 5 (or no cap) at author time, 3/5 while a run is iterating, and 5/5 in crimson once the loop hits its cap. Failed runs render an Alert banner with the structured __loopError payload (the offending node, the cap, and the last output) for triage.

Failure shapes#

A failed loop run persists a __loopError object in the run context:

{
  "kind": "iteration_exhausted",
  "nodeId": "draft",
  "cap": 5,
  "attempts": 5,
  "lastOutput": { "response": "..." },
  "message": "Iteration limit reached at node \"draft\" after 5 attempts",
}
{
  "kind": "stalled",
  "nodeId": "review",
  "attempts": 3,
  "lastFingerprint": "...",
  "lastOutput": { "result": false, "reasoning": "..." },
  "message": "Node \"review\" stalled — produced an identical result 3 times in a row",
}

Both are read by the run-view banner and available via the REST/MCP run detail endpoints for programmatic handling.

Multi-node cycles#

The cap and stall semantics work the same for cycles of any size. Three-node example (plan → fix → review → plan):

initial: plan
states:
  plan:
    type: ai_agent
    label: Plan the work
    model: claude-haiku-4-5
    prompt: 'Plan the changes given {{ review.reasoning || "" }}.'
    on:
      done: fix
  fix:
    type: ai_agent
    label: Apply the plan
    model: claude-sonnet-5
    prompt: 'Apply this plan: {{ plan.response }}.'
    on:
      done: review
  review:
    type: ai_judge
    label: Review the result
    model: claude-sonnet-5
    prompt: 'Does this meet the brief?'
    validator: { kind: boolean }
    maxIterations: 10
    on:
      'true': shipped
      'false': plan
  shipped:
    type: end
    label: Shipped
    outcome: success

Cap on review (the loop-target of the back-edge) bounds the whole cycle. The runner records iterationIndex for every node visit, so the run timeline shows each node's full visit history rather than only its most recent pass.

Variations#

  • Multiple back-edges to the same target. Several branches can route back to the same draft node; they merge into one cycle, and the cap on the target bounds all of them together.
  • Cap on the reviewer instead of the draft node. Functionally identical for most workflows. Put the cap where the constraint reads most naturally ("review at most 5 times" vs "draft at most 5 times").
  • Human-in-the-loop variant. Replace the AI judge with a human node. The cap requirement drops automatically because the loop can't run unattended.
  • Remember work across iterations. A script node in the loop can use $ROSCOE_RUN_DIR (a temporary folder unique to the run) as loop memory: read a file at the top of each pass, update it, write it back. It's keyed to the run, so concurrent runs never clobber each other, unlike the old hazard of writing to the workflow-wide $ROSCOE_WORKFLOW_DIR. See /docs/nodes/script.

Try it locally#

Ready-to-run fixtures covering every permutation live in packages/core/test-fixtures/closed-loop/:

Fixture What it demonstrates
01-self-loop-exits.yaml A single-node retry loop that exits naturally
02-self-loop-cap-exhausted.yaml iteration_exhausted when the loop never exits
03-two-node-bounded.yaml Canonical gate ↔ fix shape exiting cleanly
04-two-node-cap-exhausted.yaml Same shape, gate never passes, cap fires
05-three-node-cycle.yaml Multi-node cycle with the cap on review
06-stall-detected.yaml detectStall short-circuits identical verdicts
07-stall-output-varies.yaml detectStall allows progress when output changes
08-human-gated.yaml Human-gated cycle that doesn't need a cap

Each fixture takes a counter file via --var counterFile=/tmp/... so it can be run repeatedly without manual state cleanup.

See also#

View this page as Markdown

Predictable workflows from unpredictable AI