griptape-nodes 0.64.10__py3-none-any.whl → 0.65.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 (55) hide show
  1. griptape_nodes/app/app.py +25 -5
  2. griptape_nodes/cli/commands/init.py +65 -54
  3. griptape_nodes/cli/commands/libraries.py +92 -85
  4. griptape_nodes/cli/commands/self.py +121 -0
  5. griptape_nodes/common/node_executor.py +2142 -101
  6. griptape_nodes/exe_types/base_iterative_nodes.py +1004 -0
  7. griptape_nodes/exe_types/connections.py +114 -19
  8. griptape_nodes/exe_types/core_types.py +225 -7
  9. griptape_nodes/exe_types/flow.py +3 -3
  10. griptape_nodes/exe_types/node_types.py +681 -225
  11. griptape_nodes/exe_types/param_components/README.md +414 -0
  12. griptape_nodes/exe_types/param_components/api_key_provider_parameter.py +200 -0
  13. griptape_nodes/exe_types/param_components/huggingface/huggingface_model_parameter.py +2 -0
  14. griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_file_parameter.py +79 -5
  15. griptape_nodes/exe_types/param_types/parameter_button.py +443 -0
  16. griptape_nodes/machines/control_flow.py +77 -38
  17. griptape_nodes/machines/dag_builder.py +148 -70
  18. griptape_nodes/machines/parallel_resolution.py +61 -35
  19. griptape_nodes/machines/sequential_resolution.py +11 -113
  20. griptape_nodes/retained_mode/events/app_events.py +1 -0
  21. griptape_nodes/retained_mode/events/base_events.py +16 -13
  22. griptape_nodes/retained_mode/events/connection_events.py +3 -0
  23. griptape_nodes/retained_mode/events/execution_events.py +35 -0
  24. griptape_nodes/retained_mode/events/flow_events.py +15 -2
  25. griptape_nodes/retained_mode/events/library_events.py +347 -0
  26. griptape_nodes/retained_mode/events/node_events.py +48 -0
  27. griptape_nodes/retained_mode/events/os_events.py +86 -3
  28. griptape_nodes/retained_mode/events/project_events.py +15 -1
  29. griptape_nodes/retained_mode/events/workflow_events.py +48 -1
  30. griptape_nodes/retained_mode/griptape_nodes.py +6 -2
  31. griptape_nodes/retained_mode/managers/config_manager.py +10 -8
  32. griptape_nodes/retained_mode/managers/event_manager.py +168 -0
  33. griptape_nodes/retained_mode/managers/fitness_problems/libraries/__init__.py +2 -0
  34. griptape_nodes/retained_mode/managers/fitness_problems/libraries/old_xdg_location_warning_problem.py +43 -0
  35. griptape_nodes/retained_mode/managers/flow_manager.py +664 -123
  36. griptape_nodes/retained_mode/managers/library_manager.py +1143 -139
  37. griptape_nodes/retained_mode/managers/model_manager.py +2 -3
  38. griptape_nodes/retained_mode/managers/node_manager.py +148 -25
  39. griptape_nodes/retained_mode/managers/object_manager.py +3 -1
  40. griptape_nodes/retained_mode/managers/operation_manager.py +3 -1
  41. griptape_nodes/retained_mode/managers/os_manager.py +1158 -122
  42. griptape_nodes/retained_mode/managers/secrets_manager.py +2 -3
  43. griptape_nodes/retained_mode/managers/settings.py +21 -1
  44. griptape_nodes/retained_mode/managers/sync_manager.py +2 -3
  45. griptape_nodes/retained_mode/managers/workflow_manager.py +358 -104
  46. griptape_nodes/retained_mode/retained_mode.py +3 -3
  47. griptape_nodes/traits/button.py +44 -2
  48. griptape_nodes/traits/file_system_picker.py +2 -2
  49. griptape_nodes/utils/file_utils.py +101 -0
  50. griptape_nodes/utils/git_utils.py +1226 -0
  51. griptape_nodes/utils/library_utils.py +122 -0
  52. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/METADATA +2 -1
  53. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/RECORD +55 -47
  54. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/WHEEL +1 -1
  55. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,1004 @@
1
+ import copy
2
+ import logging
3
+ from abc import abstractmethod
4
+ from enum import StrEnum
5
+ from typing import Any, NamedTuple
6
+
7
+ from griptape_nodes.exe_types.core_types import (
8
+ ControlParameterInput,
9
+ ControlParameterOutput,
10
+ Parameter,
11
+ ParameterGroup,
12
+ ParameterMessage,
13
+ ParameterMode,
14
+ ParameterTypeBuiltin,
15
+ )
16
+ from griptape_nodes.exe_types.flow import ControlFlow
17
+ from griptape_nodes.exe_types.node_types import BaseNode
18
+
19
+
20
+ def _outgoing_connection_exists(source_node: str, source_param: str) -> bool:
21
+ """Check if a source node/parameter has any outgoing connections.
22
+
23
+ Args:
24
+ source_node: Name of the node that would be sending the connection
25
+ source_param: Name of the parameter on that node
26
+
27
+ Returns:
28
+ True if the parameter has at least one outgoing connection, False otherwise
29
+
30
+ Logic: Look in connections.outgoing_index[source_node][source_param]
31
+ """
32
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
33
+
34
+ connections = GriptapeNodes.FlowManager().get_connections()
35
+
36
+ # Check if source_node has any outgoing connections at all
37
+ source_connections = connections.outgoing_index.get(source_node)
38
+ if source_connections is None:
39
+ return False
40
+
41
+ # Check if source_param has any outgoing connections
42
+ param_connections = source_connections.get(source_param)
43
+ if param_connections is None:
44
+ return False
45
+
46
+ # Return True if connections list is populated
47
+ return bool(param_connections)
48
+
49
+
50
+ def _incoming_connection_exists(target_node: str, target_param: str) -> bool:
51
+ """Check if a target node/parameter has any incoming connections.
52
+
53
+ Args:
54
+ target_node: Name of the node that would be receiving the connection
55
+ target_param: Name of the parameter on that node
56
+
57
+ Returns:
58
+ True if the parameter has at least one incoming connection, False otherwise
59
+
60
+ Logic: Look in connections.incoming_index[target_node][target_param]
61
+ """
62
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
63
+
64
+ connections = GriptapeNodes.FlowManager().get_connections()
65
+
66
+ # Check if target_node has any incoming connections at all
67
+ target_connections = connections.incoming_index.get(target_node)
68
+ if target_connections is None:
69
+ return False
70
+
71
+ # Check if target_param has any incoming connections
72
+ param_connections = target_connections.get(target_param)
73
+ if param_connections is None:
74
+ return False
75
+
76
+ # Return True if connections list is populated
77
+ return bool(param_connections)
78
+
79
+
80
+ class StatusType(StrEnum):
81
+ """Enum for iterative loop status types."""
82
+
83
+ NORMAL = "normal"
84
+ BREAK = "break"
85
+
86
+
87
+ class NodeParameterPair(NamedTuple):
88
+ """A named tuple for storing a pair of node and parameters for connections.
89
+
90
+ Fields:
91
+ node: The node the parameter lives on
92
+ parameter: The parameter connected
93
+ """
94
+
95
+ node: BaseNode
96
+ parameter: Parameter
97
+
98
+
99
+ class BaseIterativeStartNode(BaseNode):
100
+ """Base class for all iterative start nodes (ForEach, ForLoop, etc.).
101
+
102
+ This class consolidates all shared signal logic, connection management,
103
+ state tracking, and validation logic used by iterative loop start nodes.
104
+ """
105
+
106
+ end_node: "BaseIterativeEndNode | None" = None
107
+ exec_out: ControlParameterOutput
108
+ _current_iteration_count: int
109
+ _total_iterations: int
110
+ _flow: ControlFlow | None = None
111
+ is_parallel: bool = False # Sequential by default
112
+
113
+ def __init__(self, name: str, metadata: dict[Any, Any] | None = None) -> None:
114
+ super().__init__(name, metadata)
115
+ self._current_iteration_count = 0
116
+
117
+ # This is the total number of iterations that WILL be run (calculated during init)
118
+ self._total_iterations = 0
119
+
120
+ # Connection tracking for validation
121
+ self._connected_parameters: set[str] = set()
122
+
123
+ # Main control flow
124
+ self.exec_in = ControlParameterInput(tooltip="Start Loop", name="exec_in")
125
+ self.exec_in.ui_options = {"display_name": "Start Loop"}
126
+ self.add_parameter(self.exec_in)
127
+
128
+ # On Each Item control output - moved outside group for proper rendering
129
+ self.exec_out = ControlParameterOutput(tooltip=self._get_exec_out_tooltip(), name="exec_out")
130
+ self.exec_out.ui_options = {"display_name": self._get_exec_out_display_name()}
131
+ self.add_parameter(self.exec_out)
132
+
133
+ # Create parameter group for iteration data
134
+ with ParameterGroup(name=self._get_parameter_group_name()) as group:
135
+ # Add index parameter that all iterative nodes have
136
+ self.index_count = Parameter(
137
+ name="index",
138
+ tooltip="Current index of the iteration",
139
+ type=ParameterTypeBuiltin.INT.value,
140
+ allowed_modes={ParameterMode.PROPERTY, ParameterMode.OUTPUT},
141
+ settable=False,
142
+ default_value=0,
143
+ ui_options={"hide_property": True},
144
+ )
145
+ self.add_node_element(group)
146
+
147
+ # Explicit tethering to corresponding End node (hidden)
148
+ self.loop = Parameter(
149
+ name="loop",
150
+ tooltip="Connected Loop End Node",
151
+ output_type=ParameterTypeBuiltin.ALL.value,
152
+ allowed_modes={ParameterMode.OUTPUT},
153
+ )
154
+ self.loop.ui_options = {"hide": True, "display_name": "Loop End Node"}
155
+ self.add_parameter(self.loop)
156
+
157
+ # Hidden signal inputs from End node
158
+ self.trigger_next_iteration_signal = ControlParameterInput(
159
+ tooltip="Signal from End to continue to next iteration", name="trigger_next_iteration_signal"
160
+ )
161
+ self.trigger_next_iteration_signal.ui_options = {"hide": True, "display_name": "Next Iteration Signal"}
162
+ self.trigger_next_iteration_signal.settable = False
163
+
164
+ self.break_loop_signal = ControlParameterInput(
165
+ tooltip="Signal from End to break out of loop", name="break_loop_signal"
166
+ )
167
+ self.break_loop_signal.ui_options = {"hide": True, "display_name": "Break Loop Signal"}
168
+ self.break_loop_signal.settable = False
169
+
170
+ # Hidden control output - loop end condition
171
+ self.loop_end_condition_met_signal = ControlParameterOutput(
172
+ tooltip="Signal to End when loop should end", name="loop_end_condition_met_signal"
173
+ )
174
+ self.loop_end_condition_met_signal.ui_options = {"hide": True, "display_name": "Loop End Signal"}
175
+ self.loop_end_condition_met_signal.settable = False
176
+
177
+ # Add hidden parameters
178
+ self.add_parameter(self.trigger_next_iteration_signal)
179
+ self.add_parameter(self.break_loop_signal)
180
+ self.add_parameter(self.loop_end_condition_met_signal)
181
+
182
+ # Control output tracking
183
+ self.next_control_output: Parameter | None = None
184
+ self._logger = logging.getLogger(f"{__name__}.{self.name}")
185
+
186
+ # Status message parameter - moved to bottom
187
+ self.status_message = ParameterMessage(
188
+ name="status_message",
189
+ variant="info",
190
+ value="",
191
+ )
192
+ self.add_node_element(self.status_message)
193
+
194
+ # Initialize status message
195
+ self._update_status_message()
196
+
197
+ def _get_base_node_type_name(self) -> str:
198
+ """Get the base node type name (e.g., 'ForLoop' from 'ForLoopStartNode')."""
199
+ return self.__class__.__name__.replace("StartNode", "")
200
+
201
+ @abstractmethod
202
+ def _get_compatible_end_classes(self) -> set[type]:
203
+ """Return the set of End node classes that this Start node can connect to."""
204
+
205
+ @abstractmethod
206
+ def _get_parameter_group_name(self) -> str:
207
+ """Return the name for the parameter group containing iteration data."""
208
+
209
+ @abstractmethod
210
+ def _get_exec_out_display_name(self) -> str:
211
+ """Return the display name for the exec_out parameter."""
212
+
213
+ @abstractmethod
214
+ def _get_exec_out_tooltip(self) -> str:
215
+ """Return the tooltip for the exec_out parameter."""
216
+
217
+ @abstractmethod
218
+ def _get_iteration_items(self) -> list[Any]:
219
+ """Get the list of items to iterate over."""
220
+
221
+ @abstractmethod
222
+ def _initialize_iteration_data(self) -> None:
223
+ """Initialize iteration-specific data and state."""
224
+
225
+ @abstractmethod
226
+ def _get_current_item_value(self) -> Any:
227
+ """Get the current iteration value."""
228
+
229
+ @abstractmethod
230
+ def is_loop_finished(self) -> bool:
231
+ """Return True if the loop has completed all iterations.
232
+
233
+ This method must be implemented by subclasses to define when
234
+ the loop should terminate.
235
+ """
236
+
237
+ @abstractmethod
238
+ def _get_total_iterations(self) -> int:
239
+ """Return the total number of iterations for this loop."""
240
+
241
+ @abstractmethod
242
+ def _get_current_iteration_count(self) -> int:
243
+ """Return the current iteration count (0-based)."""
244
+
245
+ @abstractmethod
246
+ def get_current_index(self) -> int:
247
+ """Return the current index value for this iteration type.
248
+
249
+ For ForEach: returns array position (0, 1, 2, ...)
250
+ For ForLoop: returns actual loop value (start, start+step, start+2*step, ...)
251
+ """
252
+
253
+ @abstractmethod
254
+ def _advance_to_next_iteration(self) -> None:
255
+ """Advance to the next iteration.
256
+
257
+ For ForEach: increment index by 1
258
+ For ForLoop: increment current value by step, increment index by 1
259
+ """
260
+
261
+ def get_all_iteration_values(self) -> list[int]:
262
+ """Calculate and return all iteration values for this loop.
263
+
264
+ For ForEach nodes, this returns indices 0, 1, 2, ...
265
+ For ForLoop nodes, this returns actual loop values (start, start+step, start+2*step, ...).
266
+
267
+ This is used by parallel execution to set correct parameter values for each iteration.
268
+
269
+ Returns:
270
+ List of integer values for each iteration
271
+ """
272
+ # Default implementation for ForEach: return 0-based indices
273
+ return list(range(self._get_total_iterations()))
274
+
275
+ def process(self) -> None:
276
+ if self._flow is None:
277
+ return
278
+
279
+ # Handle different control entry points with direct logic
280
+ match self._entry_control_parameter:
281
+ case self.exec_in | None:
282
+ # Starting the loop (initialization)
283
+ self._initialize_loop()
284
+ self._check_completion_and_set_output()
285
+ case self.trigger_next_iteration_signal:
286
+ # Next iteration signal from End - advance to next iteration
287
+ self._advance_to_next_iteration()
288
+ self._check_completion_and_set_output()
289
+ case self.break_loop_signal:
290
+ # Break signal from End - halt loop immediately
291
+ self._complete_loop(StatusType.BREAK)
292
+ case _:
293
+ # Unexpected control entry point - log error for debugging
294
+ err_str = f"Iterative Start node '{self.name}' received unexpected control parameter: {self._entry_control_parameter}. "
295
+ "Expected: exec_in, trigger_next_iteration_signal, break_loop_signal, or None."
296
+ self._logger.error(err_str)
297
+ return
298
+
299
+ def _validate_start_node(self) -> list[Exception] | None:
300
+ """Common validation logic for both workflow and node run validation."""
301
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
302
+
303
+ exceptions = []
304
+
305
+ # Validate end node connection
306
+ if self.end_node is None:
307
+ msg = f"{self.name}: End node not found or connected."
308
+ exceptions.append(Exception(msg))
309
+
310
+ # Validate all required connections exist
311
+ validation_errors = self._validate_iterative_connections()
312
+ if validation_errors:
313
+ exceptions.extend(validation_errors)
314
+
315
+ try:
316
+ flow = GriptapeNodes.ObjectManager().get_object_by_name(
317
+ GriptapeNodes.NodeManager().get_node_parent_flow_by_name(self.name)
318
+ )
319
+ if isinstance(flow, ControlFlow):
320
+ self._flow = flow
321
+ except Exception as e:
322
+ exceptions.append(e)
323
+ return exceptions
324
+
325
+ def validate_before_workflow_run(self) -> list[Exception] | None:
326
+ return self._validate_start_node()
327
+
328
+ def validate_before_node_run(self) -> list[Exception] | None:
329
+ return self._validate_start_node()
330
+
331
+ def get_next_control_output(self) -> Parameter | None:
332
+ return self.next_control_output
333
+
334
+ def allow_outgoing_connection(
335
+ self,
336
+ source_parameter: Parameter,
337
+ target_node: BaseNode,
338
+ target_parameter: Parameter,
339
+ ) -> bool:
340
+ """Validate outgoing connections for type safety."""
341
+ # Check if this is a loop tethering connection
342
+ if source_parameter == self.loop:
343
+ # Ensure target node is compatible
344
+ compatible_end_classes = self._get_compatible_end_classes()
345
+ compatible_class_names = {cls.__name__ for cls in compatible_end_classes}
346
+ target_class_name = target_node.__class__.__name__
347
+ if target_class_name not in compatible_class_names:
348
+ self._logger.warning(
349
+ "Incompatible connection: %s can only connect to %s, but attempted to connect to %s",
350
+ self.__class__.__name__,
351
+ list(compatible_class_names),
352
+ target_class_name,
353
+ )
354
+ return False
355
+ return super().allow_outgoing_connection(source_parameter, target_node, target_parameter)
356
+
357
+ def allow_incoming_connection(
358
+ self,
359
+ source_node: BaseNode,
360
+ source_parameter: Parameter,
361
+ target_parameter: Parameter,
362
+ ) -> bool:
363
+ """Validate incoming connections for type safety."""
364
+ # Check signal connections from End nodes
365
+ if target_parameter in (self.trigger_next_iteration_signal, self.break_loop_signal):
366
+ # Ensure source node is compatible
367
+ compatible_end_classes = self._get_compatible_end_classes()
368
+ compatible_class_names = {cls.__name__ for cls in compatible_end_classes}
369
+ source_class_name = source_node.__class__.__name__
370
+ if source_class_name not in compatible_class_names:
371
+ self._logger.warning(
372
+ "Incompatible connection: %s can only receive signals from %s, but %s attempted to connect",
373
+ self.__class__.__name__,
374
+ list(compatible_class_names),
375
+ source_class_name,
376
+ )
377
+ return False
378
+ return super().allow_incoming_connection(source_node, source_parameter, target_parameter)
379
+
380
+ def _update_status_message(self, status_type: StatusType = StatusType.NORMAL) -> None:
381
+ """Update the status message parameter based on current loop state."""
382
+ if self._total_iterations == 0:
383
+ # Handle the case where loop terminates immediately without iterations
384
+ status = "Completed 0 (of 0)"
385
+ elif status_type == StatusType.BREAK:
386
+ status = f"Stopped at {self._current_iteration_count} (of {self._total_iterations}) - Break"
387
+ elif self.is_loop_finished():
388
+ status = f"Completed {self._total_iterations} (of {self._total_iterations})"
389
+ else:
390
+ status = f"Processing {self._current_iteration_count} (of {self._total_iterations})"
391
+
392
+ self.status_message.value = status
393
+
394
+ def _initialize_loop(self) -> None:
395
+ """Initialize the loop with fresh parameter values."""
396
+ # Reset all state for fresh loop execution
397
+ self._current_iteration_count = 0
398
+ self.next_control_output = None
399
+
400
+ # Reset the coupled End node's state for fresh loop runs
401
+ if self.end_node and isinstance(self.end_node, BaseIterativeEndNode):
402
+ self.end_node.reset_for_workflow_run()
403
+
404
+ # Initialize iteration-specific data and set total iterations
405
+ self._initialize_iteration_data()
406
+ self._total_iterations = self._get_total_iterations()
407
+
408
+ def _check_completion_and_set_output(self) -> None:
409
+ """Check if loop should end or continue and set appropriate control output."""
410
+ if self.is_loop_finished():
411
+ self._complete_loop()
412
+ return
413
+
414
+ # Continue with current item - unresolve future nodes for fresh evaluation
415
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
416
+
417
+ connections = GriptapeNodes.FlowManager().get_connections()
418
+ connections.unresolve_future_nodes(self)
419
+
420
+ # Always set the index output in base class
421
+ current_index = self.get_current_index()
422
+ self.parameter_output_values["index"] = current_index
423
+ self.publish_update_to_parameter("index", current_index)
424
+
425
+ # Get current item value from subclass (subclasses handle their own logic)
426
+ current_item_value = self._get_current_item_value()
427
+ if current_item_value is not None:
428
+ # Subclasses can handle their own current_item logic
429
+ pass
430
+
431
+ # Update status message and continue with execution
432
+ self._update_status_message()
433
+ self.next_control_output = self.exec_out
434
+
435
+ def _complete_loop(self, status_type: StatusType = StatusType.NORMAL) -> None:
436
+ """Complete the loop and set final state."""
437
+ self._update_status_message(status_type)
438
+ self._current_iteration_count = 0
439
+ self._total_iterations = 0
440
+ self.next_control_output = self.loop_end_condition_met_signal
441
+
442
+ def _validate_iterative_connections(self) -> list[Exception]:
443
+ """Validate that all required iterative connections are properly established."""
444
+ errors = []
445
+ node_type = self._get_base_node_type_name()
446
+
447
+ # Check if exec_out has outgoing connections
448
+ if not _outgoing_connection_exists(self.name, self.exec_out.name):
449
+ exec_out_display_name = self._get_exec_out_display_name()
450
+ errors.append(
451
+ Exception(
452
+ f"{self.name}: Missing required connection from '{exec_out_display_name}'. "
453
+ f"REQUIRED ACTION: Connect {node_type} Start '{exec_out_display_name}' to interior loop nodes. "
454
+ "The start node must connect to other nodes to execute the loop body."
455
+ )
456
+ )
457
+
458
+ # Check if loop has outgoing connection to End
459
+ if self.end_node is None:
460
+ errors.append(
461
+ Exception(
462
+ f"{self.name}: Missing required tethering connection. "
463
+ f"REQUIRED ACTION: Connect {node_type} Start 'Loop End Node' to {node_type} End 'Loop Start Node'. "
464
+ "This establishes the explicit relationship between start and end nodes."
465
+ )
466
+ )
467
+
468
+ # Check if all hidden signal connections exist (only if end_node is connected)
469
+ if self.end_node:
470
+ # Check trigger_next_iteration_signal connection
471
+ if not _incoming_connection_exists(self.name, self.trigger_next_iteration_signal.name):
472
+ errors.append(
473
+ Exception(
474
+ f"{self.name}: Missing hidden signal connection. "
475
+ f"REQUIRED ACTION: Connect {node_type} End 'Next Iteration Signal Output' to {node_type} Start 'Next Iteration Signal'. "
476
+ "This signal tells the start node to continue to the next item."
477
+ )
478
+ )
479
+
480
+ # Check break_loop_signal connection
481
+ if not _incoming_connection_exists(self.name, self.break_loop_signal.name):
482
+ errors.append(
483
+ Exception(
484
+ f"{self.name}: Missing hidden signal connection. "
485
+ f"REQUIRED ACTION: Connect {node_type} End 'Break Loop Signal Output' to {node_type} Start 'Break Loop Signal'. "
486
+ "This signal tells the start node to break out of the loop early."
487
+ )
488
+ )
489
+
490
+ return errors
491
+
492
+ def after_incoming_connection(
493
+ self,
494
+ source_node: BaseNode,
495
+ source_parameter: Parameter,
496
+ target_parameter: Parameter,
497
+ ) -> None:
498
+ # Track incoming connections for validation
499
+ self._connected_parameters.add(target_parameter.name)
500
+ return super().after_incoming_connection(source_node, source_parameter, target_parameter)
501
+
502
+ def after_incoming_connection_removed(
503
+ self,
504
+ source_node: BaseNode,
505
+ source_parameter: Parameter,
506
+ target_parameter: Parameter,
507
+ ) -> None:
508
+ # Remove from tracking when connection is removed
509
+ self._connected_parameters.discard(target_parameter.name)
510
+ return super().after_incoming_connection_removed(source_node, source_parameter, target_parameter)
511
+
512
+ def after_outgoing_connection(
513
+ self,
514
+ source_parameter: Parameter,
515
+ target_node: BaseNode,
516
+ target_parameter: Parameter,
517
+ ) -> None:
518
+ if source_parameter == self.loop and isinstance(target_node, BaseIterativeEndNode):
519
+ self.end_node = target_node
520
+ return super().after_outgoing_connection(source_parameter, target_node, target_parameter)
521
+
522
+ def after_outgoing_connection_removed(
523
+ self,
524
+ source_parameter: Parameter,
525
+ target_node: BaseNode,
526
+ target_parameter: Parameter,
527
+ ) -> None:
528
+ if source_parameter == self.loop and isinstance(target_node, BaseIterativeEndNode):
529
+ self.end_node = None
530
+ return super().after_outgoing_connection_removed(source_parameter, target_node, target_parameter)
531
+
532
+
533
+ class BaseIterativeEndNode(BaseNode):
534
+ """Base class for all iterative end nodes (ForEach, ForLoop, etc.).
535
+
536
+ This class consolidates all shared signal logic, connection management,
537
+ conditional evaluation, and result accumulation logic used by iterative loop end nodes.
538
+ """
539
+
540
+ start_node: "BaseIterativeStartNode | None" = None
541
+
542
+ def __init__(self, name: str, metadata: dict[Any, Any] | None = None) -> None:
543
+ super().__init__(name, metadata)
544
+ self.start_node = None
545
+
546
+ # End node manages its own results list
547
+ self._results_list: list[Any] = []
548
+
549
+ # Connection tracking for validation
550
+ self._connected_parameters: set[str] = set()
551
+
552
+ # Explicit tethering to Start node
553
+ self.from_start = Parameter(
554
+ name="from_start",
555
+ tooltip="Connected Loop Start Node",
556
+ input_types=[ParameterTypeBuiltin.ALL.value],
557
+ allowed_modes={ParameterMode.INPUT},
558
+ )
559
+ self.from_start.ui_options = {"hide": True, "display_name": "Loop Start Node"}
560
+
561
+ # Main control input and data parameter
562
+ self.add_item_control = ControlParameterInput(
563
+ tooltip="Add current item to output and continue loop", name="add_item"
564
+ )
565
+ self.add_item_control.ui_options = {"display_name": "Add Item to Output"}
566
+
567
+ # Data input for the item to add - positioned right under Add Item control
568
+ self.new_item_to_add = Parameter(
569
+ name="new_item_to_add",
570
+ tooltip="Item to add to results list",
571
+ type=ParameterTypeBuiltin.ANY.value,
572
+ allowed_modes={ParameterMode.INPUT},
573
+ )
574
+
575
+ # Loop completion output
576
+ self.exec_out = ControlParameterOutput(tooltip="Triggered when loop completes", name="exec_out")
577
+ self.exec_out.ui_options = {"display_name": "On Loop Complete"}
578
+
579
+ # Results output - positioned below On Loop Complete
580
+ self.results = Parameter(
581
+ name="results",
582
+ tooltip="Collected loop results",
583
+ output_type="list",
584
+ allowed_modes={ParameterMode.OUTPUT},
585
+ )
586
+
587
+ # Advanced control options for skip and break
588
+ self.skip_control = ControlParameterInput(
589
+ tooltip="Skip current item and continue to next iteration", name="skip_iteration"
590
+ )
591
+ self.skip_control.ui_options = {"display_name": "Skip to Next Iteration"}
592
+
593
+ self.break_control = ControlParameterInput(tooltip="Break out of loop immediately", name="break_loop")
594
+ self.break_control.ui_options = {"display_name": "Break Out of Loop"}
595
+
596
+ # Hidden inputs from Start
597
+ self.loop_end_condition_met_signal_input = ControlParameterInput(
598
+ tooltip="Signal from Start when loop should end", name="loop_end_condition_met_signal_input"
599
+ )
600
+ self.loop_end_condition_met_signal_input.ui_options = {"hide": True, "display_name": "Loop End Signal Input"}
601
+ self.loop_end_condition_met_signal_input.settable = False
602
+
603
+ # Hidden outputs to Start
604
+ self.trigger_next_iteration_signal_output = ControlParameterOutput(
605
+ tooltip="Signal to Start to continue to next iteration", name="trigger_next_iteration_signal_output"
606
+ )
607
+ self.trigger_next_iteration_signal_output.ui_options = {
608
+ "hide": True,
609
+ "display_name": "Next Iteration Signal Output",
610
+ }
611
+ self.trigger_next_iteration_signal_output.settable = False
612
+
613
+ self.break_loop_signal_output = ControlParameterOutput(
614
+ tooltip="Signal to Start to break out of loop", name="break_loop_signal_output"
615
+ )
616
+ self.break_loop_signal_output.ui_options = {"hide": True, "display_name": "Break Loop Signal Output"}
617
+ self.break_loop_signal_output.settable = False
618
+
619
+ # Output to iteratively update results
620
+ self.results_output = None
621
+
622
+ # Add main workflow parameters first
623
+ self.add_parameter(self.add_item_control)
624
+ self.add_parameter(self.new_item_to_add)
625
+ self.add_parameter(self.exec_out)
626
+ self.add_parameter(self.results)
627
+
628
+ # Add advanced control options before tethering connection
629
+ self.add_parameter(self.skip_control)
630
+ self.add_parameter(self.break_control)
631
+
632
+ # Add hidden parameters
633
+ self.add_parameter(self.from_start)
634
+ self.add_parameter(self.loop_end_condition_met_signal_input)
635
+ self.add_parameter(self.trigger_next_iteration_signal_output)
636
+ self.add_parameter(self.break_loop_signal_output)
637
+
638
+ def _get_base_node_type_name(self) -> str:
639
+ """Get the base node type name (e.g., 'ForLoop' from 'ForLoopEndNode')."""
640
+ return self.__class__.__name__.replace("EndNode", "")
641
+
642
+ @abstractmethod
643
+ def _get_compatible_start_classes(self) -> set[type]:
644
+ """Return the set of Start node classes that this End node can connect to."""
645
+
646
+ def _output_results_list(self) -> None:
647
+ """Output the current results list to the results parameter.
648
+
649
+ Uses deep copy to ensure nested objects (like dictionaries) are properly copied
650
+ and won't have unintended side effects if modified later.
651
+ """
652
+ self.parameter_output_values["results"] = copy.deepcopy(self._results_list)
653
+
654
+ def _validate_end_node(self) -> list[Exception] | None:
655
+ """Common validation logic for both workflow and node run validation."""
656
+ exceptions = []
657
+ if self.start_node is None:
658
+ exceptions.append(Exception("Start node is not set on End Node."))
659
+
660
+ # Validate all required connections exist
661
+ validation_errors = self._validate_iterative_connections()
662
+ if validation_errors:
663
+ exceptions.extend(validation_errors)
664
+
665
+ if exceptions:
666
+ return exceptions
667
+ return super().validate_before_node_run()
668
+
669
+ def validate_before_node_run(self) -> list[Exception] | None:
670
+ return self._validate_end_node()
671
+
672
+ def validate_before_workflow_run(self) -> list[Exception] | None:
673
+ return self._validate_end_node()
674
+
675
+ def process(self) -> None:
676
+ """Process the end node based on the control path taken."""
677
+ match self._entry_control_parameter:
678
+ case self.add_item_control:
679
+ # Only evaluate new_item_to_add parameter when adding to output
680
+ new_item_value = self.get_parameter_value("new_item_to_add")
681
+ self._results_list.append(new_item_value)
682
+ if self.results_output is not None:
683
+ node, param = self.results_output
684
+ # Set the parameter value on the node. This should trigger after_value_set.
685
+ node.set_parameter_value(param.name, self._results_list)
686
+ case self.skip_control:
687
+ # Skip - don't add anything to output, just continue loop
688
+ pass
689
+ case self.break_control:
690
+ # Break - emit current results and trigger break signal in get_next_control_output
691
+ self._output_results_list()
692
+ case self.loop_end_condition_met_signal_input:
693
+ # Loop has ended naturally, output final results as standard parameter
694
+ self._output_results_list()
695
+ return
696
+
697
+ def get_next_control_output(self) -> Parameter | None:
698
+ """Return the appropriate signal output based on the control path taken."""
699
+ match self._entry_control_parameter:
700
+ case self.add_item_control | self.skip_control:
701
+ # Both add and skip trigger next iteration
702
+ return self.trigger_next_iteration_signal_output
703
+ case self.break_control:
704
+ # Break triggers break loop signal
705
+ return self.break_loop_signal_output
706
+ case self.loop_end_condition_met_signal_input:
707
+ # Loop end condition triggers normal completion
708
+ return self.exec_out
709
+ case _:
710
+ # Default fallback - should not happen
711
+ return self.exec_out
712
+
713
+ def allow_outgoing_connection(
714
+ self,
715
+ source_parameter: Parameter,
716
+ target_node: BaseNode,
717
+ target_parameter: Parameter,
718
+ ) -> bool:
719
+ """Validate outgoing connections for type safety."""
720
+ # Check signal connections to Start nodes
721
+ if source_parameter in (self.trigger_next_iteration_signal_output, self.break_loop_signal_output):
722
+ # Ensure target node is compatible
723
+ compatible_start_classes = self._get_compatible_start_classes()
724
+ compatible_class_names = {cls.__name__ for cls in compatible_start_classes}
725
+ target_class_name = target_node.__class__.__name__
726
+ if target_class_name not in compatible_class_names:
727
+ logger = logging.getLogger(__name__ + "." + self.name)
728
+ logger.warning(
729
+ "Incompatible connection: %s can only connect to %s, but attempted to connect to %s",
730
+ self.__class__.__name__,
731
+ list(compatible_class_names),
732
+ target_class_name,
733
+ )
734
+ return False
735
+ return super().allow_outgoing_connection(source_parameter, target_node, target_parameter)
736
+
737
+ def allow_incoming_connection(
738
+ self,
739
+ source_node: BaseNode,
740
+ source_parameter: Parameter,
741
+ target_parameter: Parameter,
742
+ ) -> bool:
743
+ """Validate incoming connections for type safety."""
744
+ # Check if this is a loop tethering connection
745
+ if target_parameter == self.from_start:
746
+ # Ensure source node is compatible
747
+ compatible_start_classes = self._get_compatible_start_classes()
748
+ compatible_class_names = {cls.__name__ for cls in compatible_start_classes}
749
+ source_class_name = source_node.__class__.__name__
750
+ if source_class_name not in compatible_class_names:
751
+ logger = logging.getLogger(__name__ + "." + self.name)
752
+ logger.warning(
753
+ "Incompatible connection: %s can only receive connections from %s, but %s attempted to connect",
754
+ self.__class__.__name__,
755
+ list(compatible_class_names),
756
+ source_class_name,
757
+ )
758
+ return False
759
+ return super().allow_incoming_connection(source_node, source_parameter, target_parameter)
760
+
761
+ def _validate_iterative_connections(self) -> list[Exception]:
762
+ """Validate that all required iterative connections are properly established."""
763
+ errors = []
764
+ node_type = self._get_base_node_type_name()
765
+
766
+ # Check if from_start has incoming connection from Start
767
+ if self.start_node is None:
768
+ errors.append(
769
+ Exception(
770
+ f"{self.name}: Missing required tethering connection. "
771
+ f"REQUIRED ACTION: Connect {node_type} Start 'Loop End Node' to {node_type} End 'Loop Start Node'. "
772
+ "This establishes the explicit relationship between start and end nodes."
773
+ )
774
+ )
775
+
776
+ # Check if all hidden signal connections exist (only if start_node is connected)
777
+ if self.start_node and not _incoming_connection_exists(self.name, "loop_end_condition_met_signal_input"):
778
+ errors.append(
779
+ Exception(
780
+ f"{self.name}: Missing hidden signal connection. "
781
+ f"REQUIRED ACTION: Connect {node_type} Start 'Loop End Signal' to {node_type} End 'Loop End Signal Input'. "
782
+ "This receives the signal when the loop has completed naturally."
783
+ )
784
+ )
785
+
786
+ # Check if control inputs have at least one connection
787
+ control_names = ["add_item", "skip_iteration", "break_loop"]
788
+ connected_controls = []
789
+
790
+ for control_name in control_names:
791
+ if _incoming_connection_exists(self.name, control_name):
792
+ connected_controls.append(control_name) # noqa: PERF401
793
+
794
+ if not connected_controls:
795
+ errors.append(
796
+ Exception(
797
+ f"{self.name}: No control flow connections found. "
798
+ f"REQUIRED ACTION: Connect at least one control flow to {node_type} End. "
799
+ "Options: 'Add Item to Output', 'Skip to Next Iteration', or 'Break Out of Loop'. "
800
+ "The End node needs to receive control flow from your loop body logic."
801
+ )
802
+ )
803
+
804
+ return errors
805
+
806
+ def initialize_spotlight(self) -> None:
807
+ """Custom spotlight initialization for conditional dependency resolution."""
808
+ match self._entry_control_parameter:
809
+ case self.add_item_control:
810
+ # Only resolve new_item_to_add dependency if we're actually going to use it
811
+ new_item_param = self.get_parameter_by_name("new_item_to_add")
812
+ if new_item_param and ParameterMode.INPUT in new_item_param.get_mode():
813
+ self.current_spotlight_parameter = new_item_param
814
+ case _:
815
+ # For skip or break paths, don't resolve any input dependencies
816
+ self.current_spotlight_parameter = None
817
+
818
+ def advance_parameter(self) -> bool:
819
+ """Custom parameter advancement with conditional dependency resolution."""
820
+ if self.current_spotlight_parameter is None:
821
+ return False
822
+
823
+ # Use default advancement behavior for the new_item_to_add parameter
824
+ if self.current_spotlight_parameter.next is not None:
825
+ self.current_spotlight_parameter = self.current_spotlight_parameter.next
826
+ return True
827
+
828
+ self.current_spotlight_parameter = None
829
+ return False
830
+
831
+ def reset_for_workflow_run(self) -> None:
832
+ """Reset End state for a fresh workflow run."""
833
+ self._results_list = []
834
+ self._output_results_list()
835
+
836
+ def after_incoming_connection(
837
+ self,
838
+ source_node: BaseNode,
839
+ source_parameter: Parameter,
840
+ target_parameter: Parameter,
841
+ ) -> None:
842
+ # Track incoming connections for validation
843
+ self._connected_parameters.add(target_parameter.name)
844
+
845
+ if target_parameter is self.from_start and isinstance(source_node, BaseIterativeStartNode):
846
+ self.start_node = source_node
847
+ # Auto-create all hidden signal connections when main tethering connection is made
848
+ self._create_hidden_signal_connections(source_node)
849
+ return super().after_incoming_connection(source_node, source_parameter, target_parameter)
850
+
851
+ def after_incoming_connection_removed(
852
+ self,
853
+ source_node: BaseNode,
854
+ source_parameter: Parameter,
855
+ target_parameter: Parameter,
856
+ ) -> None:
857
+ # Remove from tracking when connection is removed
858
+ self._connected_parameters.discard(target_parameter.name)
859
+
860
+ if target_parameter is self.from_start and isinstance(source_node, BaseIterativeStartNode):
861
+ self.start_node = None
862
+ # Clean up hidden signal connections when main tethering connection is removed
863
+ self._remove_hidden_signal_connections(source_node)
864
+ return super().after_incoming_connection_removed(source_node, source_parameter, target_parameter)
865
+
866
+ def _create_hidden_signal_connections(self, start_node: BaseNode) -> None:
867
+ """Automatically create all hidden signal connections between Start and End nodes."""
868
+ from griptape_nodes.retained_mode.events.connection_events import CreateConnectionRequest
869
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
870
+
871
+ # Create the hidden signal connections and default control flow:
872
+
873
+ # 1. Start → End: loop_end_condition_met_signal → loop_end_condition_met_signal_input
874
+ GriptapeNodes.handle_request(
875
+ CreateConnectionRequest(
876
+ source_node_name=start_node.name,
877
+ source_parameter_name="loop_end_condition_met_signal",
878
+ target_node_name=self.name,
879
+ target_parameter_name="loop_end_condition_met_signal_input",
880
+ )
881
+ )
882
+
883
+ # 2. End → Start: trigger_next_iteration_signal_output → trigger_next_iteration_signal
884
+ GriptapeNodes.handle_request(
885
+ CreateConnectionRequest(
886
+ source_node_name=self.name,
887
+ source_parameter_name="trigger_next_iteration_signal_output",
888
+ target_node_name=start_node.name,
889
+ target_parameter_name="trigger_next_iteration_signal",
890
+ )
891
+ )
892
+
893
+ # 3. End → Start: break_loop_signal_output → break_loop_signal
894
+ GriptapeNodes.handle_request(
895
+ CreateConnectionRequest(
896
+ source_node_name=self.name,
897
+ source_parameter_name="break_loop_signal_output",
898
+ target_node_name=start_node.name,
899
+ target_parameter_name="break_loop_signal",
900
+ )
901
+ )
902
+
903
+ # 4. Default control flow: Start → End: exec_out → add_item (default "happy path")
904
+ # Only create this connection if the exec_out parameter doesn't already have a connection
905
+ if not _outgoing_connection_exists(start_node.name, "exec_out"):
906
+ GriptapeNodes.handle_request(
907
+ CreateConnectionRequest(
908
+ source_node_name=start_node.name,
909
+ source_parameter_name="exec_out",
910
+ target_node_name=self.name,
911
+ target_parameter_name="add_item",
912
+ )
913
+ )
914
+
915
+ def _remove_hidden_signal_connections(self, start_node: BaseNode) -> None:
916
+ """Remove all hidden signal connections when the main tethering connection is removed."""
917
+ from griptape_nodes.retained_mode.events.connection_events import (
918
+ DeleteConnectionRequest,
919
+ ListConnectionsForNodeRequest,
920
+ ListConnectionsForNodeResultSuccess,
921
+ )
922
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
923
+
924
+ # Get current connections for start node to check what still exists
925
+ list_connections_result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=start_node.name))
926
+ if not isinstance(list_connections_result, ListConnectionsForNodeResultSuccess):
927
+ return # Cannot determine what connections exist, exit gracefully
928
+
929
+ # Helper function to check if a connection exists
930
+ def connection_exists(
931
+ source_node_name: str, source_param: str, target_node_name: str, target_param: str
932
+ ) -> bool:
933
+ # Check in outgoing connections from source node
934
+ for conn in list_connections_result.outgoing_connections:
935
+ if (
936
+ conn.source_parameter_name == source_param
937
+ and conn.target_node_name == target_node_name
938
+ and conn.target_parameter_name == target_param
939
+ ):
940
+ return True
941
+ # Check in incoming connections to source node
942
+ for conn in list_connections_result.incoming_connections:
943
+ if (
944
+ conn.source_node_name == source_node_name
945
+ and conn.source_parameter_name == source_param
946
+ and conn.target_parameter_name == target_param
947
+ ):
948
+ return True
949
+ return False
950
+
951
+ # Remove the hidden signal connections:
952
+
953
+ # 1. Start → End: loop_end_condition_met_signal → loop_end_condition_met_signal_input
954
+ if connection_exists(
955
+ start_node.name, "loop_end_condition_met_signal", self.name, "loop_end_condition_met_signal_input"
956
+ ):
957
+ GriptapeNodes.handle_request(
958
+ DeleteConnectionRequest(
959
+ source_node_name=start_node.name,
960
+ source_parameter_name="loop_end_condition_met_signal",
961
+ target_node_name=self.name,
962
+ target_parameter_name="loop_end_condition_met_signal_input",
963
+ )
964
+ )
965
+
966
+ # 2. End → Start: trigger_next_iteration_signal_output → trigger_next_iteration_signal
967
+ if connection_exists(
968
+ self.name, "trigger_next_iteration_signal_output", start_node.name, "trigger_next_iteration_signal"
969
+ ):
970
+ GriptapeNodes.handle_request(
971
+ DeleteConnectionRequest(
972
+ source_node_name=self.name,
973
+ source_parameter_name="trigger_next_iteration_signal_output",
974
+ target_node_name=start_node.name,
975
+ target_parameter_name="trigger_next_iteration_signal",
976
+ )
977
+ )
978
+
979
+ # 3. End → Start: break_loop_signal_output → break_loop_signal
980
+ if connection_exists(self.name, "break_loop_signal_output", start_node.name, "break_loop_signal"):
981
+ GriptapeNodes.handle_request(
982
+ DeleteConnectionRequest(
983
+ source_node_name=self.name,
984
+ source_parameter_name="break_loop_signal_output",
985
+ target_node_name=start_node.name,
986
+ target_parameter_name="break_loop_signal",
987
+ )
988
+ )
989
+
990
+ def after_outgoing_connection(
991
+ self, source_parameter: Parameter, target_node: BaseNode, target_parameter: Parameter
992
+ ) -> None:
993
+ if source_parameter == self.results:
994
+ # Update value on each iteration
995
+ self.results_output = NodeParameterPair(node=target_node, parameter=target_parameter)
996
+ return super().after_outgoing_connection(source_parameter, target_node, target_parameter)
997
+
998
+ def after_outgoing_connection_removed(
999
+ self, source_parameter: Parameter, target_node: BaseNode, target_parameter: Parameter
1000
+ ) -> None:
1001
+ if source_parameter == self.results:
1002
+ # Update value on each iteration
1003
+ self.results_output = None
1004
+ return super().after_outgoing_connection_removed(source_parameter, target_node, target_parameter)