Add a loop node that runs the same sequence of steps pass after pass, stopping on a policy or a hard cap, then picks which pass's output the node returns.
When you need this
Section titled “When you need this”Use a loop node whenever a step needs another look at its own result — filling in a record and
checking it against rules, redrafting something until it clears a check, refining an answer pass by
pass. A loop node runs the same short sequence of steps more than once, until a policy says to stop
or a hard cap is reached, then hands back whichever pass’s results a second policy picks.
- Write
<stem>.node.yaml:node: "loop", adescription,body,max_iter,select, andout.initandstopare both optional. bodyis an ordered list of sibling node ids — files that live in the same directory as the loop node itself, the same way amapnode’s single body node does. Every pass runs all of them, in that order, once each.max_iteris the hard cap on passes, from 1 to 50. Whatever else is set, the loop never runs more passes than this.stopis a list of policies, checked once every pass finishes. Each entry isuse(a built-in name) orrun(your ownmodule:function), pluswithfor whatever parameters it takes — the same policy shape used everywhere else in AQVEN. Two built-ins ship:thresholdstops once a numeric path crosses agteorltebound, andstagnationstops once a numeric path stops improving by at leastmin_deltaover awindowof passes. The first policy in the list that says stop, stops the loop. Leavestopunset and the loop always runs tomax_iterevery time, even on a pass that already stopped improving — worth setting explicitly whenever you have a real signal to stop on, the waypolishbelow does with two.selectpicks which pass’s results the node actually returns, once the loop has stopped for any reason. Same shape asstop. Two built-ins ship:lastalways picks the most recent pass, andbest, given apath, picks the pass with the highest value there.initbinds values into a body node’s input, but only before the very first pass — for seeding something a later pass will read back that doesn’t exist yet on pass one. Its keys are the same sibling idsbodyuses.- Inside a body node,
$acc.<sibling id>.out.<field>reads that sibling’s own output from the pass before this one —nullon the first pass, since there’s no pass before it yet. A body node never sees the pass it’s currently running, only the one before it. outbinds what the loop node returns.$iter.<sibling id>.out.<field>reads from the passselectpicked, which isn’t necessarily the last one that ran.$loop.iterationsis how many passes ran in total, and$loop.stop_reasonis"policy","max_iter", or"budget"— which of the three actually ended the loop.
Example
Section titled “Example”This is the showcase project’s polish node in its support_case flow: it takes the winning draft a
call node chose earlier in the flow, revises it, has a second model critique the revision, and repeats
until the critique’s score clears a threshold or stops improving, up to three passes. Create it yourself
with:
aqven new my_project --template showcaseIt sits next to its two body nodes, revise.node.yaml and critique.node.yaml, shown below. AQVEN
qualifies each body node’s own id with the loop’s —
polish__revise, polish__critique — so they stay distinct from any other revise or critique
elsewhere in the project, the same way any nested node’s id is qualified by its container.
Here’s the real file:
apiVersion: "aqven/v1"kind: "Node"node: "loop"description: "Polishes the winning draft: revises it against the critique until the score clears a threshold or stops improving"body:- "revise"- "critique"init: revise: - name: "previous" from: "$panel.out.winner"max_iter: 3stop:- use: "threshold" with: path: "$iter.critique.out.score" gte: 0.85- use: "stagnation" with: path: "$iter.critique.out.score" window: 1 min_delta: 0.02select: use: "best" with: path: "$iter.critique.out.score"out:- name: "reply" type: "ReplyDraft" description: "The best reply by the critic's score" from: "$iter.revise.out.reply"- name: "score" type: "Score" description: "The critic's score for the best reply" from: "$iter.critique.out.score"- name: "iterations" type: "Int" description: "How many revision passes ran" from: "$loop.iterations"init seeds revise’s own previous input before pass one, since it has nothing to read there
otherwise: $acc.revise.out.reply is null until a pass has actually run, so without init the first
revision would have no draft to start from.
body runs revise then critique, in that order, every pass. stop is a list of two built-in
policies, checked in order once a pass finishes: threshold stops the loop once critique’s score
for that pass reaches 0.85, and stagnation stops it once that same score goes a full window of 1
pass without improving by at least 0.02. Whichever policy in the list says stop first, stops the loop.
select: use: "best" then picks the pass with the highest score at that same path — not necessarily
the last one that ran, which is why out.reply reads from $iter.revise.out.reply for whichever pass
select picked, not just the final pass.
revise, the first body node, is an ordinary llm node that redrafts the reply using the previous
pass’s draft and the critique that followed it. critique, the second body node, is another llm
node — using a different model family so the critic isn’t grading its own work — that scores the
redraft and lists any blocking issues.
See also
Section titled “See also”- How to call a model — what
reviseandcritique, this loop’s two body nodes, actually are. - How to run a step over a collection — the other node kind whose body has its own
reference form,
$itemand$indexinstead of$acc. - Designing reliable workflows — when a
loopcritic likecritique/revisehere actually earns its cost, and why it needs a realstoppolicy. - The engineering loop — what to do when a run’s output isn’t what you expected.
- From a bad answer to a verified fix — tracing a real wrong
answer through
record, the showcase’s other loop node, pass by pass. - Node specifications — every field on
LoopNodeSpec, generated from the code. - Built-in policies and evaluators — every
stopandselectpolicy’s Python signature.