abstractflow 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. abstractflow/__init__.py +75 -95
  2. abstractflow/__main__.py +2 -0
  3. abstractflow/adapters/__init__.py +11 -0
  4. abstractflow/adapters/agent_adapter.py +124 -0
  5. abstractflow/adapters/control_adapter.py +615 -0
  6. abstractflow/adapters/effect_adapter.py +645 -0
  7. abstractflow/adapters/event_adapter.py +307 -0
  8. abstractflow/adapters/function_adapter.py +97 -0
  9. abstractflow/adapters/subflow_adapter.py +74 -0
  10. abstractflow/adapters/variable_adapter.py +317 -0
  11. abstractflow/cli.py +2 -0
  12. abstractflow/compiler.py +2027 -0
  13. abstractflow/core/__init__.py +5 -0
  14. abstractflow/core/flow.py +247 -0
  15. abstractflow/py.typed +2 -0
  16. abstractflow/runner.py +348 -0
  17. abstractflow/visual/__init__.py +43 -0
  18. abstractflow/visual/agent_ids.py +29 -0
  19. abstractflow/visual/builtins.py +789 -0
  20. abstractflow/visual/code_executor.py +214 -0
  21. abstractflow/visual/event_ids.py +33 -0
  22. abstractflow/visual/executor.py +2789 -0
  23. abstractflow/visual/interfaces.py +347 -0
  24. abstractflow/visual/models.py +252 -0
  25. abstractflow/visual/session_runner.py +168 -0
  26. abstractflow/visual/workspace_scoped_tools.py +261 -0
  27. abstractflow-0.3.0.dist-info/METADATA +413 -0
  28. abstractflow-0.3.0.dist-info/RECORD +32 -0
  29. {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/licenses/LICENSE +2 -0
  30. abstractflow-0.1.0.dist-info/METADATA +0 -238
  31. abstractflow-0.1.0.dist-info/RECORD +0 -10
  32. {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/WHEEL +0 -0
  33. {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/entry_points.txt +0 -0
  34. {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,615 @@
1
+ """Control-flow adapters for visual execution nodes (Sequence / Parallel / Loop).
2
+
3
+ These nodes implement Blueprint-style structured flow control:
4
+ - Sequence: executes Then 0, Then 1, ... in order (each branch runs to completion)
5
+ - Parallel: executes all branches, then triggers Completed (join)
6
+ - Loop: executes Loop body sequentially for each item, then triggers Done (completed)
7
+
8
+ Key constraint: AbstractRuntime has a single `current_node` cursor and no in-memory call stack
9
+ (durable execution). Therefore we encode control-flow state in `RunState.vars` (JSON-safe).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Callable, Dict, List, Optional
15
+
16
+
17
+ CONTROL_NS_KEY = "_control"
18
+ CONTROL_STACK_KEY = "stack"
19
+ CONTROL_FRAMES_KEY = "frames"
20
+
21
+
22
+ def _ensure_control(run_vars: Dict[str, Any]) -> tuple[Dict[str, Any], List[str], Dict[str, Any]]:
23
+ temp = run_vars.get("_temp")
24
+ if not isinstance(temp, dict):
25
+ temp = {}
26
+ run_vars["_temp"] = temp
27
+
28
+ ctrl = temp.get(CONTROL_NS_KEY)
29
+ if not isinstance(ctrl, dict):
30
+ ctrl = {}
31
+ temp[CONTROL_NS_KEY] = ctrl
32
+
33
+ stack = ctrl.get(CONTROL_STACK_KEY)
34
+ if not isinstance(stack, list):
35
+ stack = []
36
+ ctrl[CONTROL_STACK_KEY] = stack
37
+
38
+ frames = ctrl.get(CONTROL_FRAMES_KEY)
39
+ if not isinstance(frames, dict):
40
+ frames = {}
41
+ ctrl[CONTROL_FRAMES_KEY] = frames
42
+
43
+ return ctrl, stack, frames
44
+
45
+
46
+ def get_active_control_node_id(run_vars: Dict[str, Any]) -> Optional[str]:
47
+ """Return the active control node to resume to (top of the control stack)."""
48
+ temp = run_vars.get("_temp")
49
+ if not isinstance(temp, dict):
50
+ return None
51
+ ctrl = temp.get(CONTROL_NS_KEY)
52
+ if not isinstance(ctrl, dict):
53
+ return None
54
+ stack = ctrl.get(CONTROL_STACK_KEY)
55
+ if not isinstance(stack, list) or not stack:
56
+ return None
57
+ top = stack[-1]
58
+ return top if isinstance(top, str) and top else None
59
+
60
+
61
+ def create_sequence_node_handler(
62
+ *,
63
+ node_id: str,
64
+ ordered_then_handles: List[str],
65
+ targets_by_handle: Dict[str, str],
66
+ ) -> Callable:
67
+ """Create a visual Sequence node handler (Then 0, Then 1, ...)."""
68
+
69
+ from abstractruntime.core.models import StepPlan
70
+
71
+ ordered = [h for h in ordered_then_handles if isinstance(h, str) and h]
72
+
73
+ def handler(run: Any, ctx: Any) -> "StepPlan":
74
+ # ctx unused (runtime-owned effects happen in other nodes)
75
+ _ctrl, stack, frames = _ensure_control(run.vars)
76
+
77
+ frame = frames.get(node_id)
78
+ if not isinstance(frame, dict):
79
+ frame = {"kind": "sequence", "idx": 0, "then": list(ordered)}
80
+ frames[node_id] = frame
81
+ stack.append(node_id)
82
+
83
+ # Ensure this node is the active scheduler on the control stack.
84
+ if not stack or stack[-1] != node_id:
85
+ # Be conservative: push if missing. (Should be rare; handles resume/re-entry.)
86
+ stack.append(node_id)
87
+
88
+ try:
89
+ idx = int(frame.get("idx", 0) or 0)
90
+ except Exception:
91
+ idx = 0
92
+
93
+ then_handles = frame.get("then")
94
+ if not isinstance(then_handles, list):
95
+ then_handles = list(ordered)
96
+ frame["then"] = then_handles
97
+
98
+ # Dispatch next connected branch in order.
99
+ while idx < len(then_handles):
100
+ handle = then_handles[idx]
101
+ idx += 1
102
+ if not isinstance(handle, str) or not handle:
103
+ continue
104
+ target = targets_by_handle.get(handle)
105
+ if isinstance(target, str) and target:
106
+ frame["idx"] = idx
107
+ return StepPlan(node_id=node_id, next_node=target)
108
+
109
+ # Done: pop frame and return to parent control node if any, else complete.
110
+ frames.pop(node_id, None)
111
+ if stack and stack[-1] == node_id:
112
+ stack.pop()
113
+ else:
114
+ # Remove any stray occurrences
115
+ stack[:] = [x for x in stack if x != node_id]
116
+
117
+ parent = stack[-1] if stack and isinstance(stack[-1], str) and stack[-1] else None
118
+ if parent:
119
+ return StepPlan(node_id=node_id, next_node=parent)
120
+ return StepPlan(
121
+ node_id=node_id,
122
+ complete_output={"success": True, "result": run.vars.get("_last_output")},
123
+ )
124
+
125
+ return handler
126
+
127
+
128
+ def create_parallel_node_handler(
129
+ *,
130
+ node_id: str,
131
+ ordered_then_handles: List[str],
132
+ targets_by_handle: Dict[str, str],
133
+ completed_target: Optional[str],
134
+ ) -> Callable:
135
+ """Create a visual Parallel node handler (fan-out + join).
136
+
137
+ Note: The current runtime executes a single cursor; therefore this parallel node
138
+ provides fork/join *semantics* but executes branches deterministically (in pin order).
139
+ """
140
+
141
+ from abstractruntime.core.models import StepPlan
142
+
143
+ ordered = [h for h in ordered_then_handles if isinstance(h, str) and h]
144
+ completed = completed_target if isinstance(completed_target, str) and completed_target else None
145
+
146
+ def handler(run: Any, ctx: Any) -> "StepPlan":
147
+ _ctrl, stack, frames = _ensure_control(run.vars)
148
+
149
+ frame = frames.get(node_id)
150
+ if not isinstance(frame, dict):
151
+ frame = {"kind": "parallel", "phase": "branches", "idx": 0, "then": list(ordered)}
152
+ if completed:
153
+ frame["completed_target"] = completed
154
+ frames[node_id] = frame
155
+ stack.append(node_id)
156
+
157
+ if not stack or stack[-1] != node_id:
158
+ stack.append(node_id)
159
+
160
+ phase = frame.get("phase")
161
+ if phase != "completed":
162
+ try:
163
+ idx = int(frame.get("idx", 0) or 0)
164
+ except Exception:
165
+ idx = 0
166
+
167
+ then_handles = frame.get("then")
168
+ if not isinstance(then_handles, list):
169
+ then_handles = list(ordered)
170
+ frame["then"] = then_handles
171
+
172
+ while idx < len(then_handles):
173
+ handle = then_handles[idx]
174
+ idx += 1
175
+ if not isinstance(handle, str) or not handle:
176
+ continue
177
+ target = targets_by_handle.get(handle)
178
+ if isinstance(target, str) and target:
179
+ frame["idx"] = idx
180
+ return StepPlan(node_id=node_id, next_node=target)
181
+
182
+ frame["phase"] = "completed"
183
+
184
+ # Join point: run Completed chain (if connected), otherwise return up/complete.
185
+ frames.pop(node_id, None)
186
+ if stack and stack[-1] == node_id:
187
+ stack.pop()
188
+ else:
189
+ stack[:] = [x for x in stack if x != node_id]
190
+
191
+ completed_target2 = completed
192
+ if isinstance(frame, dict):
193
+ ct = frame.get("completed_target")
194
+ if isinstance(ct, str) and ct:
195
+ completed_target2 = ct
196
+
197
+ if completed_target2:
198
+ return StepPlan(node_id=node_id, next_node=completed_target2)
199
+
200
+ parent = stack[-1] if stack and isinstance(stack[-1], str) and stack[-1] else None
201
+ if parent:
202
+ return StepPlan(node_id=node_id, next_node=parent)
203
+ return StepPlan(
204
+ node_id=node_id,
205
+ complete_output={"success": True, "result": run.vars.get("_last_output")},
206
+ )
207
+
208
+ return handler
209
+
210
+
211
+ def create_loop_node_handler(
212
+ *,
213
+ node_id: str,
214
+ loop_target: Optional[str],
215
+ done_target: Optional[str],
216
+ resolve_items: Callable[[Any], List[Any]],
217
+ ) -> Callable:
218
+ """Create a visual Loop (Foreach) node handler.
219
+
220
+ Semantics (Blueprint-style):
221
+ - On entry: resolve `items` once and store them durably in the control frame.
222
+ - For each item: set `{item, index}` outputs and schedule the Loop body chain.
223
+ - When the Loop body chain ends (or pauses/resumes), control returns here via the
224
+ active control stack, and the next item is scheduled.
225
+ - After the last item: schedule Done (if connected) or return to parent control.
226
+ """
227
+
228
+ from abstractruntime.core.models import StepPlan
229
+
230
+ loop_next = loop_target if isinstance(loop_target, str) and loop_target else None
231
+ done_next = done_target if isinstance(done_target, str) and done_target else None
232
+
233
+ def _persist_node_output(run_vars: Dict[str, Any], value: Dict[str, Any]) -> None:
234
+ temp = run_vars.get("_temp")
235
+ if not isinstance(temp, dict):
236
+ temp = {}
237
+ run_vars["_temp"] = temp
238
+ persisted = temp.get("node_outputs")
239
+ if not isinstance(persisted, dict):
240
+ persisted = {}
241
+ temp["node_outputs"] = persisted
242
+ persisted[node_id] = value
243
+
244
+ def handler(run: Any, ctx: Any) -> "StepPlan":
245
+ del ctx
246
+ _ctrl, stack, frames = _ensure_control(run.vars)
247
+
248
+ frame = frames.get(node_id)
249
+ if not isinstance(frame, dict):
250
+ items = list(resolve_items(run))
251
+ frame = {"kind": "loop", "idx": 0, "items": items}
252
+ frames[node_id] = frame
253
+ stack.append(node_id)
254
+
255
+ # Ensure this node is the active scheduler on the control stack.
256
+ if not stack or stack[-1] != node_id:
257
+ stack.append(node_id)
258
+
259
+ try:
260
+ idx = int(frame.get("idx", 0) or 0)
261
+ except Exception:
262
+ idx = 0
263
+
264
+ items = frame.get("items")
265
+ if not isinstance(items, list):
266
+ items = list(resolve_items(run))
267
+ frame["items"] = items
268
+
269
+ # If no loop body is connected, treat as a no-op and go to Done/parent.
270
+ if not loop_next or idx >= len(items):
271
+ frames.pop(node_id, None)
272
+ if stack and stack[-1] == node_id:
273
+ stack.pop()
274
+ else:
275
+ stack[:] = [x for x in stack if x != node_id]
276
+
277
+ if done_next:
278
+ return StepPlan(node_id=node_id, next_node=done_next)
279
+
280
+ parent = stack[-1] if stack and isinstance(stack[-1], str) and stack[-1] else None
281
+ if parent:
282
+ return StepPlan(node_id=node_id, next_node=parent)
283
+ return StepPlan(node_id=node_id, complete_output={"success": True, "result": run.vars.get("_last_output")})
284
+
285
+ # Emit per-iteration outputs without losing the pipeline output from prior nodes/iterations.
286
+ current_item = items[idx]
287
+ out: Dict[str, Any]
288
+ base = run.vars.get("_last_output")
289
+ if isinstance(base, dict):
290
+ out = dict(base)
291
+ else:
292
+ out = {"input": base}
293
+ out["item"] = current_item
294
+ out["index"] = idx
295
+ # Helpful for UI observability: show progress as (index+1)/total for Foreach loops.
296
+ # This is purely additive and does not change loop semantics.
297
+ out["total"] = len(items)
298
+
299
+ run.vars["_last_output"] = out
300
+ _persist_node_output(run.vars, out)
301
+
302
+ # Advance idx *before* scheduling the body so pause/resume can't repeat an item.
303
+ frame["idx"] = idx + 1
304
+ return StepPlan(node_id=node_id, next_node=loop_next)
305
+
306
+ return handler
307
+
308
+
309
+ def create_for_node_handler(
310
+ *,
311
+ node_id: str,
312
+ loop_target: Optional[str],
313
+ done_target: Optional[str],
314
+ resolve_range: Callable[[Any], Dict[str, Any]],
315
+ max_iterations: int = 10_000,
316
+ ) -> Callable:
317
+ """Create a visual For node handler (numeric range).
318
+
319
+ Semantics (Blueprint-style):
320
+ - On entry: resolve {start,end,step} once and store them durably in the control frame.
321
+ - Each iteration: emit outputs {i, index, total} and schedule Loop body.
322
+ - When the loop finishes: schedule Done (if connected) or return to parent control / complete.
323
+
324
+ Notes:
325
+ - End is exclusive, like Python's range(): step>0 runs while i<end, step<0 runs while i>end.
326
+ - We store iteration state durably (i, index, total) for pause/resume safety.
327
+ - A max_iterations guard prevents accidental infinite/huge loops.
328
+ """
329
+
330
+ from abstractruntime.core.models import StepPlan
331
+
332
+ loop_next = loop_target if isinstance(loop_target, str) and loop_target else None
333
+ done_next = done_target if isinstance(done_target, str) and done_target else None
334
+
335
+ def _persist_node_output(run_vars: Dict[str, Any], value: Dict[str, Any]) -> None:
336
+ temp = run_vars.get("_temp")
337
+ if not isinstance(temp, dict):
338
+ temp = {}
339
+ run_vars["_temp"] = temp
340
+ persisted = temp.get("node_outputs")
341
+ if not isinstance(persisted, dict):
342
+ persisted = {}
343
+ temp["node_outputs"] = persisted
344
+ persisted[node_id] = value
345
+
346
+ def _to_number(raw: Any) -> Optional[float]:
347
+ try:
348
+ if raw is None:
349
+ return None
350
+ if isinstance(raw, bool):
351
+ return float(int(raw))
352
+ if isinstance(raw, (int, float)):
353
+ return float(raw)
354
+ if isinstance(raw, str) and raw.strip():
355
+ return float(raw.strip())
356
+ except Exception:
357
+ return None
358
+ return None
359
+
360
+ def _compute_total(start: float, end: float, step: float) -> int:
361
+ # Best-effort: used for observability only.
362
+ try:
363
+ if step == 0:
364
+ return 0
365
+ if step > 0:
366
+ span = end - start
367
+ if span <= 0:
368
+ return 0
369
+ import math
370
+
371
+ return int(math.ceil(span / step))
372
+ # step < 0
373
+ span = start - end
374
+ if span <= 0:
375
+ return 0
376
+ import math
377
+
378
+ return int(math.ceil(span / (-step)))
379
+ except Exception:
380
+ return 0
381
+
382
+ def handler(run: Any, ctx: Any) -> "StepPlan":
383
+ del ctx
384
+ _ctrl, stack, frames = _ensure_control(run.vars)
385
+
386
+ frame = frames.get(node_id)
387
+ if not isinstance(frame, dict):
388
+ resolved = resolve_range(run) if callable(resolve_range) else {}
389
+ resolved = resolved if isinstance(resolved, dict) else {}
390
+
391
+ start = _to_number(resolved.get("start"))
392
+ end = _to_number(resolved.get("end"))
393
+ step = _to_number(resolved.get("step"))
394
+ if step is None:
395
+ step = 1.0
396
+
397
+ if start is None or end is None:
398
+ run.vars["_flow_error"] = "For loop requires numeric 'start' and 'end'."
399
+ run.vars["_flow_error_node"] = node_id
400
+ return StepPlan(
401
+ node_id=node_id,
402
+ complete_output={"success": False, "error": run.vars["_flow_error"], "node": node_id},
403
+ )
404
+
405
+ if step == 0:
406
+ run.vars["_flow_error"] = "For loop requires a non-zero 'step'."
407
+ run.vars["_flow_error_node"] = node_id
408
+ return StepPlan(
409
+ node_id=node_id,
410
+ complete_output={"success": False, "error": run.vars["_flow_error"], "node": node_id},
411
+ )
412
+
413
+ total = _compute_total(start, end, step)
414
+ frame = {"kind": "for", "start": start, "end": end, "step": step, "i": start, "index": 0, "total": total}
415
+ frames[node_id] = frame
416
+ stack.append(node_id)
417
+
418
+ # Ensure this node is the active scheduler on the control stack.
419
+ if not stack or stack[-1] != node_id:
420
+ stack.append(node_id)
421
+
422
+ # If no loop body is connected, treat as a no-op.
423
+ if not loop_next:
424
+ frames.pop(node_id, None)
425
+ if stack and stack[-1] == node_id:
426
+ stack.pop()
427
+ else:
428
+ stack[:] = [x for x in stack if x != node_id]
429
+
430
+ if done_next:
431
+ return StepPlan(node_id=node_id, next_node=done_next)
432
+ parent = stack[-1] if stack and isinstance(stack[-1], str) and stack[-1] else None
433
+ if parent:
434
+ return StepPlan(node_id=node_id, next_node=parent)
435
+ return StepPlan(node_id=node_id, complete_output={"success": True, "result": run.vars.get("_last_output")})
436
+
437
+ try:
438
+ idx = int(frame.get("index", 0) or 0)
439
+ except Exception:
440
+ idx = 0
441
+
442
+ if max_iterations > 0 and idx >= max_iterations:
443
+ run.vars["_flow_error"] = f"For loop exceeded max_iterations={max_iterations}"
444
+ run.vars["_flow_error_node"] = node_id
445
+ frames.pop(node_id, None)
446
+ stack[:] = [x for x in stack if x != node_id]
447
+ return StepPlan(
448
+ node_id=node_id,
449
+ complete_output={"success": False, "error": run.vars["_flow_error"], "node": node_id},
450
+ )
451
+
452
+ try:
453
+ cur = float(frame.get("i", 0.0) or 0.0)
454
+ except Exception:
455
+ cur = 0.0
456
+ try:
457
+ end = float(frame.get("end", 0.0) or 0.0)
458
+ except Exception:
459
+ end = 0.0
460
+ try:
461
+ step = float(frame.get("step", 1.0) or 1.0)
462
+ except Exception:
463
+ step = 1.0
464
+
465
+ # Termination (end-exclusive).
466
+ done = (cur >= end) if step > 0 else (cur <= end)
467
+ if done:
468
+ frames.pop(node_id, None)
469
+ if stack and stack[-1] == node_id:
470
+ stack.pop()
471
+ else:
472
+ stack[:] = [x for x in stack if x != node_id]
473
+
474
+ if done_next:
475
+ return StepPlan(node_id=node_id, next_node=done_next)
476
+ parent = stack[-1] if stack and isinstance(stack[-1], str) and stack[-1] else None
477
+ if parent:
478
+ return StepPlan(node_id=node_id, next_node=parent)
479
+ return StepPlan(node_id=node_id, complete_output={"success": True, "result": run.vars.get("_last_output")})
480
+
481
+ # Emit per-iteration outputs without clobbering the pipeline output.
482
+ base = run.vars.get("_last_output")
483
+ out: Dict[str, Any]
484
+ if isinstance(base, dict):
485
+ out = dict(base)
486
+ else:
487
+ out = {"input": base}
488
+ out["i"] = cur
489
+ out["index"] = idx
490
+ total = frame.get("total")
491
+ if isinstance(total, int):
492
+ out["total"] = total
493
+
494
+ run.vars["_last_output"] = out
495
+ _persist_node_output(run.vars, out)
496
+
497
+ # Advance state before scheduling the body (pause/resume safety).
498
+ frame["i"] = cur + step
499
+ frame["index"] = idx + 1
500
+ return StepPlan(node_id=node_id, next_node=loop_next)
501
+
502
+ return handler
503
+
504
+
505
+ def create_while_node_handler(
506
+ *,
507
+ node_id: str,
508
+ loop_target: Optional[str],
509
+ done_target: Optional[str],
510
+ resolve_condition: Callable[[Any], bool],
511
+ max_iterations: int = 10_000,
512
+ ) -> Callable:
513
+ """Create a visual While node handler.
514
+
515
+ Semantics (Blueprint-style):
516
+ - Evaluate `condition` each time the node is entered.
517
+ - If true: schedule Loop body.
518
+ - If false: schedule Done (if connected) or return to parent control / complete.
519
+
520
+ Notes:
521
+ - Single-cursor runtime: this provides loop semantics deterministically.
522
+ - `max_iterations` is a safety cap to avoid accidental infinite loops.
523
+ """
524
+
525
+ from abstractruntime.core.models import StepPlan
526
+
527
+ loop_next = loop_target if isinstance(loop_target, str) and loop_target else None
528
+ done_next = done_target if isinstance(done_target, str) and done_target else None
529
+
530
+ def _persist_node_output(run_vars: Dict[str, Any], value: Dict[str, Any]) -> None:
531
+ temp = run_vars.get("_temp")
532
+ if not isinstance(temp, dict):
533
+ temp = {}
534
+ run_vars["_temp"] = temp
535
+ persisted = temp.get("node_outputs")
536
+ if not isinstance(persisted, dict):
537
+ persisted = {}
538
+ temp["node_outputs"] = persisted
539
+ persisted[node_id] = value
540
+
541
+ def handler(run: Any, ctx: Any) -> "StepPlan":
542
+ del ctx
543
+ _ctrl, stack, frames = _ensure_control(run.vars)
544
+
545
+ frame = frames.get(node_id)
546
+ if not isinstance(frame, dict):
547
+ frame = {"kind": "while", "iters": 0}
548
+ frames[node_id] = frame
549
+ stack.append(node_id)
550
+
551
+ if not stack or stack[-1] != node_id:
552
+ stack.append(node_id)
553
+
554
+ try:
555
+ iters = int(frame.get("iters", 0) or 0)
556
+ except Exception:
557
+ iters = 0
558
+
559
+ if max_iterations > 0 and iters >= max_iterations:
560
+ run.vars["_flow_error"] = f"While loop exceeded max_iterations={max_iterations}"
561
+ run.vars["_flow_error_node"] = node_id
562
+ frames.pop(node_id, None)
563
+ stack[:] = [x for x in stack if x != node_id]
564
+ return StepPlan(
565
+ node_id=node_id,
566
+ complete_output={"success": False, "error": run.vars["_flow_error"], "node": node_id},
567
+ )
568
+
569
+ cond = bool(resolve_condition(run))
570
+ if not cond or not loop_next:
571
+ # Exit: pop frame and proceed to Done/parent/complete.
572
+ frames.pop(node_id, None)
573
+ if stack and stack[-1] == node_id:
574
+ stack.pop()
575
+ else:
576
+ stack[:] = [x for x in stack if x != node_id]
577
+
578
+ if done_next:
579
+ return StepPlan(node_id=node_id, next_node=done_next)
580
+
581
+ parent = stack[-1] if stack and isinstance(stack[-1], str) and stack[-1] else None
582
+ if parent:
583
+ return StepPlan(node_id=node_id, next_node=parent)
584
+ return StepPlan(
585
+ node_id=node_id,
586
+ complete_output={"success": True, "result": run.vars.get("_last_output")},
587
+ )
588
+
589
+ # Emit an iteration index (Blueprint-style convenience, like Foreach.index).
590
+ base = run.vars.get("_last_output")
591
+ out: Dict[str, Any]
592
+ if isinstance(base, dict):
593
+ out = dict(base)
594
+ else:
595
+ out = {"input": base}
596
+ # Expose `item:any` for parity with Foreach loops.
597
+ #
598
+ # Semantics:
599
+ # - If an upstream scheduler already set `item` (e.g. nested Loop), preserve it.
600
+ # - Otherwise, treat the current pipeline value as the loop "item".
601
+ #
602
+ # This is intentionally conservative: existing flows that rely on a prior `item`
603
+ # (from an outer loop) keep working, while standalone While loops gain a usable
604
+ # `item` output for wiring into the loop body.
605
+ if "item" not in out:
606
+ out["item"] = base
607
+ out["index"] = iters
608
+ run.vars["_last_output"] = out
609
+ _persist_node_output(run.vars, out)
610
+
611
+ frame["iters"] = iters + 1
612
+ return StepPlan(node_id=node_id, next_node=loop_next)
613
+
614
+ return handler
615
+