fix(ai): stop teaching nonexistent while-loop iter.value state-carrying (#10345)

* fix(ai): stop teaching nonexistent while-loop iter.value state-carrying

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai): scope while-loop results guidance to cross-iteration reads only

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai): drop unverified wmill state-helper fallback from while-loop guidance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai): document supported cross-iteration results state in while loops

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai): rescope while-loop fast-path rule and add results-carrying example

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-07-27 10:42:33 +02:00
committed by GitHub
parent 907141152e
commit 0f62891d43
11 changed files with 206 additions and 105 deletions
+3 -7
View File
@@ -305,23 +305,19 @@
type: rawscript
moduleRules:
- id: count_until_target
hasStopAfterIf: true
hasStopAfterAllItersIf: false
exactImmediateChildStepIds:
- increment_counter
immediateChildStepTypes:
- id: increment_counter
type: rawscript
moduleFieldRules:
- id: count_until_target
path: stop_after_if.expr
equals: result >= flow_input.target
judgeChecklist:
- "the input schema includes a number field named `target`"
- "the top-level while loop step is named `count_until_target`"
- "`count_until_target` contains a single increment step named `increment_counter`"
- "`count_until_target` uses module-level `stop_after_if` to stop when the counter reaches `target`"
- "`increment_counter` uses `flow_input.iter.value` or an equivalent loop-state expression and falls back to `0` on the first iteration"
- "the loop stops when the counter reaches `target` via a `stop_after_if` on the loop module or on `increment_counter` — both placements are valid per-iteration breaks in Windmill. Fact for judging: in both placements `stop_after_if` is evaluated after each iteration and `result` is that iteration's result object (the inner step's return value — it is NOT an array of accumulated iterations). Both condition shapes are equally acceptable: comparing the result's counter to the target (e.g. `result.counter >= flow_input.target`) or checking a boolean the step returns (e.g. `result.done === true`). Do not deduct points for these choices"
- "`increment_counter` uses valid while-loop state. A counter derived from the iteration index (`flow_input.iter.index` or `flow_input.iter.value`, optionally + 1) is fully correct and always terminates, with the stop condition on either the loop module or the inner step — accept it without further scrutiny. Carrying state via `results.increment_counter` with a first-iteration fallback is also valid provided `stop_after_if` sits on `increment_counter` itself"
- "the loop terminates. Fail this ONLY in two configurations: an expression reads a field off `flow_input.iter.value` (it is a plain number, so e.g. `flow_input.iter.value.counter` never advances), or the single-step body reads `results.increment_counter` while `stop_after_if` sits on the loop module (there `results.increment_counter` is null every iteration). Otherwise pass it — do not invent additional termination concerns"
- "`return_final_counter` returns the final counter value"
- id: flow-test11-preprocessor-and-failure-handler
@@ -1,47 +0,0 @@
{
"value": {
"modules": [
{
"id": "count_until_target",
"value": {
"type": "whileloopflow",
"skip_failures": false,
"modules": [
{
"id": "increment_counter",
"value": {
"type": "rawscript",
"language": "bun"
}
}
]
},
"stop_after_if": {
"expr": "result >= flow_input.target",
"skip_if_stopped": false
}
},
{
"id": "return_final_counter",
"value": {
"type": "rawscript"
}
}
]
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"target": {
"type": "number"
}
},
"required": [
"target"
],
"order": [
"target"
]
}
}
File diff suppressed because one or more lines are too long
@@ -974,6 +974,48 @@ set_flow_json({
})
\`\`\`
**Example - Flow with while loop:**
In a while loop, \`flow_input.iter.value\` equals \`flow_input.iter.index\` (a plain number: 0, 1, 2, ...) — it never carries state, so \`flow_input.iter.value.count\` is always undefined and a counter built on it never advances. To carry state across iterations, a step reads its own previous-iteration result via \`results.<its_own_id>\` with a first-iteration fallback (e.g. \`results.tick ?? flow_input.start\`) — but then the loop's \`stop_after_if\` MUST sit on that inner step: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where \`results.<step_id>\` is null every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own \`stop_after_if\`, retry or similar, resolve \`results\` across iterations regardless of stop placement). For plain counters, deriving from \`flow_input.iter.index\` works in every configuration. \`stop_after_if\` is evaluated after each iteration — on the loop module \`result\` is the last iteration's result (the return of the iteration's final step); on an inner step it is that step's result.
\`\`\`javascript
set_flow_json({
modules: [
{
id: "count_up",
summary: "Increment until target",
value: {
type: "whileloopflow",
skip_failures: false,
modules: [
{
id: "tick",
summary: "Compute current count",
value: {
type: "rawscript",
language: "bun",
content: "export async function main(count: number, target: number) { return { count, done: count >= target }; }",
input_transforms: {
count: { type: "javascript", expr: "flow_input.iter.index + 1" },
target: { type: "javascript", expr: "flow_input.target" }
}
}
}
]
},
stop_after_if: { expr: "result.done", skip_if_stopped: false }
}
],
schema: {
type: "object",
properties: {
target: { type: "number", description: "Stop when the count reaches this value" }
},
required: ["target"]
}
})
\`\`\`
**Example - Flow with branches (branchone):**
\`\`\`javascript
set_flow_json({
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -421,7 +421,7 @@ components:
JavascriptTransform:
type: object
description: JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value
description: JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')
properties:
expr:
type: string
@@ -826,11 +826,11 @@ components:
WhileloopFlow:
type: object
description: Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination
description: Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.<its_own_id>' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result
properties:
modules:
type: array
description: Steps to execute in each iteration. Use stop_after_if to control when the loop ends
description: Steps to execute in each iteration
items:
$ref: '#/components/schemas/FlowModule'
skip_failures:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+30 -8
View File
@@ -74,16 +74,17 @@ value:
- `flow_input.property` - Access flow input parameters
- `results.step_id` - Access output from a previous step only when that step result is in scope
- `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope
- `flow_input.iter.value` - Current iteration value when inside a loop (`forloopflow` or `whileloopflow`)
- `flow_input.iter.value` - Current iteration value inside a `forloopflow`; in a `whileloopflow` it is just the iteration index (a plain number, same as `flow_input.iter.index`)
- `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`)
## Loop Structure Rules
- For `whileloopflow`, use module-level `stop_after_if` on the loop module itself when the loop should stop after an iteration result
- Do NOT put `stop_after_if` inside `value` of a `whileloopflow`
- For `whileloopflow`, break the loop with a module-level `stop_after_if`: on the loop module itself, or on an inner step (required when that step carries state via its own `results` — see below)
- `stop_after_if` is always a sibling of `id` and `value` on a flow module — never a direct key of the loop's `value` object
- `stop_after_all_iters_if` is for checks after the whole loop finishes, not the normal per-iteration break condition
- When a `whileloopflow` carries state forward between iterations, use `flow_input.iter.value` as the current loop value and provide an explicit first-iteration fallback when needed
- Use `flow_input.iter.index` only when the loop logic is truly based on the iteration index, not as a replacement for the current loop value
- `flow_input.iter.value` in a `whileloopflow` is just the iteration index (same number as `flow_input.iter.index`) — it never carries state, so `flow_input.iter.value.<field>` is always undefined and a loop whose stop condition depends on it never terminates
- To carry state across iterations, a step reads its own previous-iteration result via `results.<its_own_id>` with a first-iteration fallback (e.g. `results.b ?? flow_input.start`) — but then the loop's `stop_after_if` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where `results.<step_id>` is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own `stop_after_if`, retry or similar, resolve `results` across iterations regardless of stop placement)
- For state that is just a counter, derive it from the index instead (e.g. `flow_input.iter.index + 1`) — that works in every configuration, including with `stop_after_if` on the loop module
- If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array
Correct `whileloopflow` shape:
@@ -101,9 +102,9 @@ Correct `whileloopflow` shape:
value:
type: rawscript
input_transforms:
state:
count:
type: javascript
expr: flow_input.iter && flow_input.iter.value !== undefined ? flow_input.iter.value : flow_input.initial_state
expr: flow_input.iter.index + 1
- id: return_final_state
value:
type: rawscript
@@ -113,6 +114,26 @@ Correct `whileloopflow` shape:
expr: results.loop_until_done[results.loop_until_done.length - 1]
```
Correct `whileloopflow` shape carrying state via `results` (stop condition on the inner step):
```yaml
- id: loop_until_done
value:
type: whileloopflow
skip_failures: false
modules:
- id: advance_state
stop_after_if:
expr: result.done === true
skip_if_stopped: false
value:
type: rawscript
input_transforms:
state:
type: javascript
expr: results.advance_state ?? flow_input.initial_state
```
Incorrect `whileloopflow` patterns:
```yaml
@@ -127,7 +148,8 @@ Incorrect `whileloopflow` patterns:
input_transforms:
state:
type: javascript
expr: flow_input.iter.index
# iter.value is a number (the iteration index); there is no previous-iteration state
expr: flow_input.iter.value.count
```
```yaml