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.
- abstractflow/__init__.py +75 -95
- abstractflow/__main__.py +2 -0
- abstractflow/adapters/__init__.py +11 -0
- abstractflow/adapters/agent_adapter.py +124 -0
- abstractflow/adapters/control_adapter.py +615 -0
- abstractflow/adapters/effect_adapter.py +645 -0
- abstractflow/adapters/event_adapter.py +307 -0
- abstractflow/adapters/function_adapter.py +97 -0
- abstractflow/adapters/subflow_adapter.py +74 -0
- abstractflow/adapters/variable_adapter.py +317 -0
- abstractflow/cli.py +2 -0
- abstractflow/compiler.py +2027 -0
- abstractflow/core/__init__.py +5 -0
- abstractflow/core/flow.py +247 -0
- abstractflow/py.typed +2 -0
- abstractflow/runner.py +348 -0
- abstractflow/visual/__init__.py +43 -0
- abstractflow/visual/agent_ids.py +29 -0
- abstractflow/visual/builtins.py +789 -0
- abstractflow/visual/code_executor.py +214 -0
- abstractflow/visual/event_ids.py +33 -0
- abstractflow/visual/executor.py +2789 -0
- abstractflow/visual/interfaces.py +347 -0
- abstractflow/visual/models.py +252 -0
- abstractflow/visual/session_runner.py +168 -0
- abstractflow/visual/workspace_scoped_tools.py +261 -0
- abstractflow-0.3.0.dist-info/METADATA +413 -0
- abstractflow-0.3.0.dist-info/RECORD +32 -0
- {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/licenses/LICENSE +2 -0
- abstractflow-0.1.0.dist-info/METADATA +0 -238
- abstractflow-0.1.0.dist-info/RECORD +0 -10
- {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/WHEEL +0 -0
- {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/entry_points.txt +0 -0
- {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
|
+
|