griptape-nodes 0.53.0__py3-none-any.whl → 0.54.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 (56) hide show
  1. griptape_nodes/__init__.py +5 -2
  2. griptape_nodes/app/app.py +4 -26
  3. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +35 -5
  4. griptape_nodes/bootstrap/workflow_executors/workflow_executor.py +15 -1
  5. griptape_nodes/cli/commands/config.py +4 -1
  6. griptape_nodes/cli/commands/init.py +5 -3
  7. griptape_nodes/cli/commands/libraries.py +14 -8
  8. griptape_nodes/cli/commands/models.py +504 -0
  9. griptape_nodes/cli/commands/self.py +5 -2
  10. griptape_nodes/cli/main.py +11 -1
  11. griptape_nodes/cli/shared.py +0 -9
  12. griptape_nodes/common/directed_graph.py +17 -1
  13. griptape_nodes/drivers/storage/base_storage_driver.py +40 -20
  14. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +24 -29
  15. griptape_nodes/drivers/storage/local_storage_driver.py +17 -13
  16. griptape_nodes/exe_types/node_types.py +219 -14
  17. griptape_nodes/exe_types/param_components/__init__.py +1 -0
  18. griptape_nodes/exe_types/param_components/execution_status_component.py +138 -0
  19. griptape_nodes/machines/control_flow.py +129 -92
  20. griptape_nodes/machines/dag_builder.py +207 -0
  21. griptape_nodes/machines/parallel_resolution.py +264 -276
  22. griptape_nodes/machines/sequential_resolution.py +9 -7
  23. griptape_nodes/node_library/library_registry.py +34 -1
  24. griptape_nodes/retained_mode/events/app_events.py +5 -1
  25. griptape_nodes/retained_mode/events/base_events.py +7 -7
  26. griptape_nodes/retained_mode/events/config_events.py +30 -0
  27. griptape_nodes/retained_mode/events/execution_events.py +2 -2
  28. griptape_nodes/retained_mode/events/model_events.py +296 -0
  29. griptape_nodes/retained_mode/griptape_nodes.py +10 -1
  30. griptape_nodes/retained_mode/managers/agent_manager.py +14 -0
  31. griptape_nodes/retained_mode/managers/config_manager.py +44 -3
  32. griptape_nodes/retained_mode/managers/event_manager.py +8 -2
  33. griptape_nodes/retained_mode/managers/flow_manager.py +45 -14
  34. griptape_nodes/retained_mode/managers/library_manager.py +3 -3
  35. griptape_nodes/retained_mode/managers/model_manager.py +1107 -0
  36. griptape_nodes/retained_mode/managers/node_manager.py +26 -26
  37. griptape_nodes/retained_mode/managers/object_manager.py +1 -1
  38. griptape_nodes/retained_mode/managers/os_manager.py +6 -6
  39. griptape_nodes/retained_mode/managers/settings.py +87 -9
  40. griptape_nodes/retained_mode/managers/static_files_manager.py +77 -9
  41. griptape_nodes/retained_mode/managers/sync_manager.py +10 -5
  42. griptape_nodes/retained_mode/managers/workflow_manager.py +98 -92
  43. griptape_nodes/retained_mode/retained_mode.py +19 -0
  44. griptape_nodes/servers/__init__.py +1 -0
  45. griptape_nodes/{mcp_server/server.py → servers/mcp.py} +1 -1
  46. griptape_nodes/{app/api.py → servers/static.py} +43 -40
  47. griptape_nodes/traits/button.py +124 -6
  48. griptape_nodes/traits/multi_options.py +188 -0
  49. griptape_nodes/traits/numbers_selector.py +77 -0
  50. griptape_nodes/traits/options.py +93 -2
  51. griptape_nodes/utils/async_utils.py +31 -0
  52. {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.0.dist-info}/METADATA +3 -1
  53. {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.0.dist-info}/RECORD +56 -47
  54. {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.0.dist-info}/WHEEL +1 -1
  55. /griptape_nodes/{mcp_server → servers}/ws_request_manager.py +0 -0
  56. {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,138 @@
1
+ from typing import Any
2
+
3
+ from griptape_nodes.exe_types.core_types import (
4
+ Parameter,
5
+ ParameterGroup,
6
+ ParameterMode,
7
+ )
8
+
9
+
10
+ class ExecutionStatusComponent:
11
+ """A reusable component for managing execution status parameters.
12
+
13
+ This component creates and manages a "Status" ParameterGroup containing:
14
+ - was_successful: Boolean parameter indicating success/failure
15
+ - result_details: String parameter with operation details
16
+
17
+ The component can be customized for different parameter modes to support
18
+ various node types (EndNode uses INPUT/PROPERTY, SuccessFailureNode uses OUTPUT).
19
+ """
20
+
21
+ def __init__( # noqa: PLR0913
22
+ self,
23
+ node: Any, # BaseNode type, but avoiding circular import
24
+ *,
25
+ was_successful_modes: set[ParameterMode],
26
+ result_details_modes: set[ParameterMode],
27
+ parameter_group_initially_collapsed: bool = True,
28
+ result_details_tooltip: str = "Details about the operation result",
29
+ result_details_placeholder: str = "Details on the operation will be presented here.",
30
+ ) -> None:
31
+ """Initialize the ExecutionStatusComponent and create the parameters immediately.
32
+
33
+ Args:
34
+ node: The node instance that will own these parameters
35
+ was_successful_modes: Set of ParameterModes for was_successful parameter
36
+ result_details_modes: Set of ParameterModes for result_details parameter
37
+ parameter_group_initially_collapsed: Whether the Status group should start collapsed
38
+ result_details_tooltip: Custom tooltip for result_details parameter
39
+ result_details_placeholder: Custom placeholder text for result_details parameter
40
+ """
41
+ self._node = node
42
+
43
+ # Create the Status ParameterGroup
44
+ self._status_group = ParameterGroup(name="Status")
45
+ self._status_group.ui_options = {"collapsed": parameter_group_initially_collapsed}
46
+
47
+ # Boolean parameter to indicate success/failure
48
+ self._was_successful = Parameter(
49
+ name="was_successful",
50
+ tooltip="Indicates whether it completed without errors.",
51
+ type="bool",
52
+ default_value=False,
53
+ settable=False,
54
+ allowed_modes=was_successful_modes,
55
+ )
56
+
57
+ # Result details parameter with multiline option
58
+ self._result_details = Parameter(
59
+ name="result_details",
60
+ tooltip=result_details_tooltip,
61
+ type="str",
62
+ default_value=None,
63
+ allowed_modes=result_details_modes,
64
+ settable=False,
65
+ ui_options={
66
+ "multiline": True,
67
+ "placeholder_text": result_details_placeholder,
68
+ },
69
+ )
70
+
71
+ # Add parameters to the group
72
+ self._status_group.add_child(self._was_successful)
73
+ self._status_group.add_child(self._result_details)
74
+
75
+ # Add the group to the node
76
+ self._node.add_node_element(self._status_group)
77
+
78
+ def get_parameter_group(self) -> ParameterGroup:
79
+ """Get the Status ParameterGroup.
80
+
81
+ Returns:
82
+ ParameterGroup: The Status group containing was_successful and result_details
83
+ """
84
+ return self._status_group
85
+
86
+ def set_execution_result(self, *, was_successful: bool, result_details: str) -> None:
87
+ """Set the execution result values.
88
+
89
+ Args:
90
+ was_successful: Whether the operation succeeded
91
+ result_details: Details about the operation result
92
+ """
93
+ self._update_parameter_value(self._was_successful, was_successful)
94
+ self._update_parameter_value(self._result_details, result_details)
95
+
96
+ def clear_execution_status(self, initial_message: str | None = None) -> None:
97
+ """Clear execution status and reset parameters.
98
+
99
+ Args:
100
+ initial_message: Initial message to set in result_details. If None, clears result_details entirely.
101
+ """
102
+ if initial_message is None:
103
+ initial_message = ""
104
+ self.set_execution_result(was_successful=False, result_details=initial_message)
105
+
106
+ def append_to_result_details(self, additional_text: str, separator: str = "\n") -> None:
107
+ """Append text to the existing result_details.
108
+
109
+ Args:
110
+ additional_text: Text to append to the current result_details
111
+ separator: Separator to use between existing and new text (default: newline)
112
+ """
113
+ # Get current result_details value
114
+ current_details = self._node.get_parameter_value(self._result_details.name)
115
+
116
+ # Append the new text
117
+ if current_details:
118
+ updated_details = f"{current_details}{separator}{additional_text}"
119
+ else:
120
+ updated_details = additional_text
121
+
122
+ # Use consolidated update method
123
+ self._update_parameter_value(self._result_details, updated_details)
124
+
125
+ def _update_parameter_value(self, parameter: Parameter, value: Any) -> None:
126
+ """Update a parameter value with all necessary operations.
127
+
128
+ Args:
129
+ parameter: The parameter to update
130
+ value: The new value to set
131
+ """
132
+ # ALWAYS set parameter value and publish update
133
+ self._node.set_parameter_value(parameter.name, value)
134
+ self._node.publish_update_to_parameter(parameter.name, value)
135
+
136
+ # ONLY set output values if the parameter mode is OUTPUT
137
+ if ParameterMode.OUTPUT in parameter.get_mode():
138
+ self._node.parameter_output_values[parameter.name] = value
@@ -39,7 +39,7 @@ logger = logging.getLogger("griptape_nodes")
39
39
  # This is the control flow context. Owns the Resolution Machine
40
40
  class ControlFlowContext:
41
41
  flow: ControlFlow
42
- current_node: BaseNode | None
42
+ current_nodes: list[BaseNode]
43
43
  resolution_machine: ParallelResolutionMachine | SequentialResolutionMachine
44
44
  selected_output: Parameter | None
45
45
  paused: bool = False
@@ -54,35 +54,57 @@ class ControlFlowContext:
54
54
  ) -> None:
55
55
  self.flow_name = flow_name
56
56
  if execution_type == WorkflowExecutionMode.PARALLEL:
57
- self.resolution_machine = ParallelResolutionMachine(flow_name, max_nodes_in_parallel)
57
+ # Get the global DagBuilder from FlowManager
58
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
59
+
60
+ dag_builder = GriptapeNodes.FlowManager().global_dag_builder
61
+ self.resolution_machine = ParallelResolutionMachine(
62
+ flow_name, max_nodes_in_parallel, dag_builder=dag_builder
63
+ )
58
64
  else:
59
65
  self.resolution_machine = SequentialResolutionMachine()
60
- self.current_node = None
66
+ self.current_nodes = []
61
67
 
62
- def get_next_node(self, output_parameter: Parameter) -> NextNodeInfo | None:
63
- """Get the next node and the target parameter that will receive the control flow.
68
+ def get_next_nodes(self, output_parameter: Parameter | None = None) -> list[NextNodeInfo]:
69
+ """Get all next nodes from the current nodes.
64
70
 
65
71
  Returns:
66
- NextNodeInfo | None: Information about the next node or None if no connection
72
+ list[NextNodeInfo]: List of next nodes to process
67
73
  """
68
- if self.current_node is not None:
69
- node_connection = (
70
- GriptapeNodes.FlowManager().get_connections().get_connected_node(self.current_node, output_parameter)
71
- )
72
- if node_connection is not None:
73
- node, entry_parameter = node_connection
74
- return NextNodeInfo(node=node, entry_parameter=entry_parameter)
75
- # Continue Execution to the next node that needs to be executed using global execution queue
76
- # Get the next node in the execution queue, or None if queue is empty
74
+ next_nodes = []
75
+ for current_node in self.current_nodes:
76
+ if output_parameter is not None:
77
+ # Get connected node from control flow
78
+ node_connection = (
79
+ GriptapeNodes.FlowManager().get_connections().get_connected_node(current_node, output_parameter)
80
+ )
81
+ if node_connection is not None:
82
+ node, entry_parameter = node_connection
83
+ next_nodes.append(NextNodeInfo(node=node, entry_parameter=entry_parameter))
84
+ else:
85
+ # Get next control output for this node
86
+ next_output = current_node.get_next_control_output()
87
+ if next_output is not None:
88
+ node_connection = (
89
+ GriptapeNodes.FlowManager().get_connections().get_connected_node(current_node, next_output)
90
+ )
91
+ if node_connection is not None:
92
+ node, entry_parameter = node_connection
93
+ next_nodes.append(NextNodeInfo(node=node, entry_parameter=entry_parameter))
94
+
95
+ # If no connections found, check execution queue
96
+ if not next_nodes:
77
97
  node = GriptapeNodes.FlowManager().get_next_node_from_execution_queue()
78
98
  if node is not None:
79
- return NextNodeInfo(node=node, entry_parameter=None)
80
- return None
99
+ next_nodes.append(NextNodeInfo(node=node, entry_parameter=None))
100
+
101
+ return next_nodes
81
102
 
82
103
  def reset(self, *, cancel: bool = False) -> None:
83
- if self.current_node:
84
- self.current_node.clear_node()
85
- self.current_node = None
104
+ if self.current_nodes is not None:
105
+ for node in self.current_nodes:
106
+ node.clear_node()
107
+ self.current_nodes = []
86
108
  self.resolution_machine.reset_machine(cancel=cancel)
87
109
  self.selected_output = None
88
110
  self.paused = False
@@ -93,24 +115,25 @@ class ResolveNodeState(State):
93
115
  @staticmethod
94
116
  async def on_enter(context: ControlFlowContext) -> type[State] | None:
95
117
  # The state machine has started, but it hasn't began to execute yet.
96
- if context.current_node is None:
118
+ if len(context.current_nodes) == 0:
97
119
  # We don't have anything else to do. Move back to Complete State so it has to restart.
98
120
  return CompleteState
99
121
 
100
- # Mark the node unresolved, and broadcast an event to the GUI.
101
- if not context.current_node.lock:
102
- context.current_node.make_node_unresolved(
103
- current_states_to_trigger_change_event=set(
104
- {NodeResolutionState.UNRESOLVED, NodeResolutionState.RESOLVED, NodeResolutionState.RESOLVING}
122
+ # Mark all current nodes unresolved and broadcast events
123
+ for current_node in context.current_nodes:
124
+ if not current_node.lock:
125
+ current_node.make_node_unresolved(
126
+ current_states_to_trigger_change_event=set(
127
+ {NodeResolutionState.UNRESOLVED, NodeResolutionState.RESOLVED, NodeResolutionState.RESOLVING}
128
+ )
129
+ )
130
+ # Now broadcast that we have a current control node.
131
+ GriptapeNodes.EventManager().put_event(
132
+ ExecutionGriptapeNodeEvent(
133
+ wrapped_event=ExecutionEvent(payload=CurrentControlNodeEvent(node_name=current_node.name))
105
134
  )
106
135
  )
107
- # Now broadcast that we have a current control node.
108
- GriptapeNodes.EventManager().put_event(
109
- ExecutionGriptapeNodeEvent(
110
- wrapped_event=ExecutionEvent(payload=CurrentControlNodeEvent(node_name=context.current_node.name))
111
- )
112
- )
113
- logger.info("Resolving %s", context.current_node.name)
136
+ logger.info("Resolving %s", current_node.name)
114
137
  if not context.paused:
115
138
  # Call the update. Otherwise wait
116
139
  return ResolveNodeState
@@ -119,13 +142,17 @@ class ResolveNodeState(State):
119
142
  # This is necessary to transition to the next step.
120
143
  @staticmethod
121
144
  async def on_update(context: ControlFlowContext) -> type[State] | None:
122
- # If node has not already been resolved!
123
- if context.current_node is None:
145
+ # If no current nodes, we're done
146
+ if len(context.current_nodes) == 0:
124
147
  return CompleteState
125
- if context.current_node.state != NodeResolutionState.RESOLVED:
126
- await context.resolution_machine.resolve_node(context.current_node)
148
+
149
+ # Resolve nodes - pass first node for sequential resolution
150
+ current_node = context.current_nodes[0] if context.current_nodes else None
151
+ await context.resolution_machine.resolve_node(current_node)
127
152
 
128
153
  if context.resolution_machine.is_complete():
154
+ if isinstance(context.resolution_machine, ParallelResolutionMachine):
155
+ return CompleteState
129
156
  return NextNodeState
130
157
  return None
131
158
 
@@ -133,44 +160,49 @@ class ResolveNodeState(State):
133
160
  class NextNodeState(State):
134
161
  @staticmethod
135
162
  async def on_enter(context: ControlFlowContext) -> type[State] | None:
136
- if context.current_node is None:
163
+ if len(context.current_nodes) == 0:
137
164
  return CompleteState
138
- # I did define this on the ControlNode.
139
- if context.current_node.stop_flow:
140
- # We're done here.
141
- context.current_node.stop_flow = False
165
+
166
+ # Check for stop_flow on any current nodes
167
+ for current_node in context.current_nodes[:]:
168
+ if current_node.stop_flow:
169
+ current_node.stop_flow = False
170
+ context.current_nodes.remove(current_node)
171
+
172
+ # If all nodes stopped flow, complete
173
+ if len(context.current_nodes) == 0:
142
174
  return CompleteState
143
- next_output = context.current_node.get_next_control_output()
144
- next_node_info = None
145
175
 
146
- if next_output is not None:
147
- context.selected_output = next_output
148
- next_node_info = context.get_next_node(context.selected_output)
149
- GriptapeNodes.EventManager().put_event(
150
- ExecutionGriptapeNodeEvent(
151
- wrapped_event=ExecutionEvent(
152
- payload=SelectedControlOutputEvent(
153
- node_name=context.current_node.name,
154
- selected_output_parameter_name=next_output.name,
176
+ # Get all next nodes from current nodes
177
+ next_node_infos = context.get_next_nodes()
178
+
179
+ # Broadcast selected control output events for nodes with outputs
180
+ for current_node in context.current_nodes:
181
+ next_output = current_node.get_next_control_output()
182
+ if next_output is not None:
183
+ context.selected_output = next_output
184
+ GriptapeNodes.EventManager().put_event(
185
+ ExecutionGriptapeNodeEvent(
186
+ wrapped_event=ExecutionEvent(
187
+ payload=SelectedControlOutputEvent(
188
+ node_name=current_node.name,
189
+ selected_output_parameter_name=next_output.name,
190
+ )
155
191
  )
156
192
  )
157
193
  )
158
- )
159
- else:
160
- # Get the next node in the execution queue, or None if queue is empty
161
- next_node = GriptapeNodes.FlowManager().get_next_node_from_execution_queue()
162
- if next_node is not None:
163
- next_node_info = NextNodeInfo(node=next_node, entry_parameter=None)
164
-
165
- # The parameter that will be evaluated next
166
- if next_node_info is None:
167
- # If no node attached
194
+
195
+ # If no next nodes, we're complete
196
+ if not next_node_infos:
168
197
  return CompleteState
169
198
 
170
- # Always set the entry control parameter (None for execution queue nodes)
171
- next_node_info.node.set_entry_control_parameter(next_node_info.entry_parameter)
199
+ # Set up next nodes as current nodes
200
+ next_nodes = []
201
+ for next_node_info in next_node_infos:
202
+ next_node_info.node.set_entry_control_parameter(next_node_info.entry_parameter)
203
+ next_nodes.append(next_node_info.node)
172
204
 
173
- context.current_node = next_node_info.node
205
+ context.current_nodes = next_nodes
174
206
  context.selected_output = None
175
207
  if not context.paused:
176
208
  return ResolveNodeState
@@ -184,15 +216,14 @@ class NextNodeState(State):
184
216
  class CompleteState(State):
185
217
  @staticmethod
186
218
  async def on_enter(context: ControlFlowContext) -> type[State] | None:
187
- if context.current_node is not None:
219
+ # Broadcast completion events for any remaining current nodes
220
+ for current_node in context.current_nodes:
188
221
  GriptapeNodes.EventManager().put_event(
189
222
  ExecutionGriptapeNodeEvent(
190
223
  wrapped_event=ExecutionEvent(
191
224
  payload=ControlFlowResolvedEvent(
192
- end_node_name=context.current_node.name,
193
- parameter_output_values=TypeValidator.safe_serialize(
194
- context.current_node.parameter_output_values
195
- ),
225
+ end_node_name=current_node.name,
226
+ parameter_output_values=TypeValidator.safe_serialize(current_node.parameter_output_values),
196
227
  )
197
228
  )
198
229
  )
@@ -218,10 +249,13 @@ class ControlFlowMachine(FSM[ControlFlowContext]):
218
249
  async def start_flow(self, start_node: BaseNode, debug_mode: bool = False) -> None: # noqa: FBT001, FBT002
219
250
  # If using DAG resolution, process data_nodes from queue first
220
251
  if isinstance(self._context.resolution_machine, ParallelResolutionMachine):
221
- await self._process_data_nodes_for_dag()
222
- self._context.current_node = start_node
252
+ current_nodes = await self._process_nodes_for_dag(start_node)
253
+ else:
254
+ current_nodes = [start_node]
255
+ self._context.current_nodes = current_nodes
223
256
  # Set entry control parameter for initial node (None for workflow start)
224
- start_node.set_entry_control_parameter(None)
257
+ for node in current_nodes:
258
+ node.set_entry_control_parameter(None)
225
259
  # Set up to debug
226
260
  self._context.paused = debug_mode
227
261
  await self.start(ResolveNodeState) # Begins the flow
@@ -266,38 +300,41 @@ class ControlFlowMachine(FSM[ControlFlowContext]):
266
300
  ):
267
301
  await self.update()
268
302
 
269
- async def _process_data_nodes_for_dag(self) -> None:
303
+ async def _process_nodes_for_dag(self, start_node: BaseNode) -> list[BaseNode]:
270
304
  """Process data_nodes from the global queue to build unified DAG.
271
305
 
272
306
  This method identifies data_nodes in the execution queue and processes
273
307
  their dependencies into the DAG resolution machine.
274
308
  """
275
309
  if not isinstance(self._context.resolution_machine, ParallelResolutionMachine):
276
- return
310
+ return []
277
311
  # Get the global flow queue
278
312
  flow_manager = GriptapeNodes.FlowManager()
313
+ dag_builder = flow_manager.global_dag_builder
314
+ if dag_builder is None:
315
+ msg = "DAG builder is not initialized."
316
+ raise ValueError(msg)
317
+ # Build with the first node:
318
+ dag_builder.add_node_with_dependencies(start_node, start_node.name)
279
319
  queue_items = list(flow_manager.global_flow_queue.queue)
280
-
320
+ start_nodes = [start_node]
281
321
  # Find data_nodes and remove them from queue
282
- data_nodes = []
283
322
  for item in queue_items:
284
323
  from griptape_nodes.retained_mode.managers.flow_manager import DagExecutionType
285
324
 
286
- if item.dag_execution_type == DagExecutionType.DATA_NODE:
287
- data_nodes.append(item.node)
325
+ if item.dag_execution_type in (DagExecutionType.CONTROL_NODE, DagExecutionType.START_NODE):
326
+ node = item.node
327
+ node.state = NodeResolutionState.UNRESOLVED
328
+ dag_builder.add_node_with_dependencies(node, node.name)
288
329
  flow_manager.global_flow_queue.queue.remove(item)
289
-
290
- # Build DAG for each data node
291
- for node in data_nodes:
292
- node.state = NodeResolutionState.UNRESOLVED
293
- await self._context.resolution_machine.resolve_node(node, build_only=True)
294
-
295
- # Run resolution until complete for this node's subgraph
296
- while not self._context.resolution_machine.is_complete():
297
- await self._context.resolution_machine.update()
298
-
299
- # Reset the machine state to allow adding more nodes
300
- self._context.resolution_machine.current_state = None
330
+ start_nodes.append(node)
331
+ elif item.dag_execution_type == DagExecutionType.DATA_NODE:
332
+ node = item.node
333
+ node.state = NodeResolutionState.UNRESOLVED
334
+ # Build here.
335
+ dag_builder.add_node_with_dependencies(node, node.name)
336
+ flow_manager.global_flow_queue.queue.remove(item)
337
+ return start_nodes
301
338
 
302
339
  def reset_machine(self, *, cancel: bool = False) -> None:
303
340
  self._context.reset(cancel=cancel)
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass, field
5
+ from enum import StrEnum
6
+ from typing import TYPE_CHECKING
7
+
8
+ from griptape_nodes.common.directed_graph import DirectedGraph
9
+ from griptape_nodes.exe_types.core_types import ParameterTypeBuiltin
10
+ from griptape_nodes.exe_types.node_types import NodeResolutionState
11
+
12
+ if TYPE_CHECKING:
13
+ import asyncio
14
+
15
+ from griptape_nodes.exe_types.connections import Connections
16
+ from griptape_nodes.exe_types.node_types import BaseNode
17
+
18
+ logger = logging.getLogger("griptape_nodes")
19
+
20
+
21
+ class NodeState(StrEnum):
22
+ """Individual node execution states."""
23
+
24
+ QUEUED = "queued"
25
+ PROCESSING = "processing"
26
+ DONE = "done"
27
+ CANCELED = "canceled"
28
+ ERRORED = "errored"
29
+ WAITING = "waiting"
30
+
31
+
32
+ @dataclass(kw_only=True)
33
+ class DagNode:
34
+ """Represents a node in the DAG with runtime references."""
35
+
36
+ task_reference: asyncio.Task | None = field(default=None)
37
+ node_state: NodeState = field(default=NodeState.WAITING)
38
+ node_reference: BaseNode
39
+
40
+
41
+ class DagBuilder:
42
+ """Handles DAG construction independently of execution state machine."""
43
+
44
+ graphs: dict[str, DirectedGraph] # Str is the name of the start node associated here.
45
+ node_to_reference: dict[str, DagNode]
46
+
47
+ def __init__(self) -> None:
48
+ self.graphs = {}
49
+ self.node_to_reference: dict[str, DagNode] = {}
50
+
51
+ # Complex with the inner recursive method, but it needs connections and added_nodes.
52
+ def add_node_with_dependencies(self, node: BaseNode, graph_name: str = "default") -> list[BaseNode]: # noqa: C901
53
+ """Add node and all its dependencies to DAG. Returns list of added nodes."""
54
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
55
+
56
+ connections = GriptapeNodes.FlowManager().get_connections()
57
+ added_nodes = []
58
+ graph = self.graphs.get(graph_name, None)
59
+ if graph is None:
60
+ graph = DirectedGraph()
61
+ self.graphs[graph_name] = graph
62
+
63
+ def _add_node_recursive(current_node: BaseNode, visited: set[str], graph: DirectedGraph) -> None:
64
+ if current_node.name in visited:
65
+ return
66
+ visited.add(current_node.name)
67
+
68
+ # Skip if already in DAG (use DAG membership, not resolved state)
69
+ if current_node.name in self.node_to_reference:
70
+ return
71
+
72
+ # Process dependencies first (depth-first)
73
+ ignore_data_dependencies = False
74
+ # This is specifically for output_selector. Overriding 'initialize_spotlight' doesn't work anymore.
75
+ if hasattr(current_node, "ignore_dependencies"):
76
+ ignore_data_dependencies = True
77
+ for param in current_node.parameters:
78
+ if param.type == ParameterTypeBuiltin.CONTROL_TYPE:
79
+ continue
80
+ if ignore_data_dependencies:
81
+ continue
82
+ upstream_connection = connections.get_connected_node(current_node, param)
83
+ if upstream_connection:
84
+ upstream_node, _ = upstream_connection
85
+ # Don't add nodes that have already been resolved.
86
+ if upstream_node.state == NodeResolutionState.RESOLVED:
87
+ continue
88
+ # If upstream is already in DAG, skip creating edge (it's in another graph)
89
+ if upstream_node.name in self.node_to_reference:
90
+ graph.add_edge(upstream_node.name, current_node.name)
91
+ # Otherwise, add it to DAG first then create edge
92
+ else:
93
+ _add_node_recursive(upstream_node, visited, graph)
94
+ graph.add_edge(upstream_node.name, current_node.name)
95
+
96
+ # Add current node to DAG (but keep original resolution state)
97
+
98
+ dag_node = DagNode(node_reference=current_node, node_state=NodeState.WAITING)
99
+ self.node_to_reference[current_node.name] = dag_node
100
+ graph.add_node(node_for_adding=current_node.name)
101
+ # DON'T mark as resolved - that happens during actual execution
102
+ added_nodes.append(current_node)
103
+
104
+ _add_node_recursive(node, set(), graph)
105
+
106
+ return added_nodes
107
+
108
+ def add_node(self, node: BaseNode, graph_name: str = "default") -> DagNode:
109
+ """Add just one node to DAG without dependencies (assumes dependencies already exist)."""
110
+ if node.name in self.node_to_reference:
111
+ return self.node_to_reference[node.name]
112
+
113
+ dag_node = DagNode(node_reference=node, node_state=NodeState.WAITING)
114
+ self.node_to_reference[node.name] = dag_node
115
+ graph = self.graphs.get(graph_name, None)
116
+ if graph is None:
117
+ graph = DirectedGraph()
118
+ self.graphs[graph_name] = graph
119
+ graph.add_node(node_for_adding=node.name)
120
+ return dag_node
121
+
122
+ def clear(self) -> None:
123
+ """Clear all nodes and references from the DAG builder."""
124
+ self.graphs.clear()
125
+ self.node_to_reference.clear()
126
+
127
+ def can_queue_control_node(self, node: DagNode) -> bool:
128
+ if len(self.graphs) == 1:
129
+ return True
130
+
131
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
132
+
133
+ connections = GriptapeNodes.FlowManager().get_connections()
134
+
135
+ control_connections = self.get_number_incoming_control_connections(node.node_reference, connections)
136
+ if control_connections <= 1:
137
+ return True
138
+
139
+ for graph in self.graphs.values():
140
+ # If the length of the graph is 0, skip it. it's either reached it or it's a dead end.
141
+ if len(graph.nodes()) == 0:
142
+ continue
143
+
144
+ # If graph has nodes, the root node (not the leaf, the root), check forward path from that
145
+ root_nodes = [n for n in graph.nodes() if graph.out_degree(n) == 0]
146
+ for root_node_name in root_nodes:
147
+ if root_node_name in self.node_to_reference:
148
+ root_node = self.node_to_reference[root_node_name].node_reference
149
+
150
+ # Skip if the root node is the same as the target node - it can't reach itself
151
+ if root_node == node.node_reference:
152
+ continue
153
+
154
+ # Check if the target node is in the forward path from this root
155
+ if self._is_node_in_forward_path(root_node, node.node_reference, connections):
156
+ return False # This graph could still reach the target node
157
+
158
+ # Otherwise, return true at the end of the function
159
+ return True
160
+
161
+ def get_number_incoming_control_connections(self, node: BaseNode, connections: Connections) -> int:
162
+ if node.name not in connections.incoming_index:
163
+ return 0
164
+
165
+ control_connection_count = 0
166
+ node_connections = connections.incoming_index[node.name]
167
+
168
+ for param_name, connection_ids in node_connections.items():
169
+ # Find the parameter to check if it's a control type
170
+ param = node.get_parameter_by_name(param_name)
171
+ if param and ParameterTypeBuiltin.CONTROL_TYPE.value in param.input_types:
172
+ control_connection_count += len(connection_ids)
173
+
174
+ return control_connection_count
175
+
176
+ def _is_node_in_forward_path(
177
+ self, start_node: BaseNode, target_node: BaseNode, connections: Connections, visited: set[str] | None = None
178
+ ) -> bool:
179
+ """Check if target_node is reachable from start_node through control flow connections."""
180
+ if visited is None:
181
+ visited = set()
182
+
183
+ if start_node.name in visited:
184
+ return False
185
+ visited.add(start_node.name)
186
+
187
+ # Check ALL outgoing control connections, not just get_next_control_output()
188
+ # This handles IfElse nodes that have multiple possible control outputs
189
+ if start_node.name in connections.outgoing_index:
190
+ for param_name, connection_ids in connections.outgoing_index[start_node.name].items():
191
+ # Find the parameter to check if it's a control type
192
+ param = start_node.get_parameter_by_name(param_name)
193
+ if param and param.output_type == ParameterTypeBuiltin.CONTROL_TYPE.value:
194
+ # This is a control parameter - check all its connections
195
+ for connection_id in connection_ids:
196
+ if connection_id in connections.connections:
197
+ connection = connections.connections[connection_id]
198
+ next_node = connection.target_node
199
+
200
+ if next_node.name == target_node.name:
201
+ return True
202
+
203
+ # Recursively check the forward path
204
+ if self._is_node_in_forward_path(next_node, target_node, connections, visited):
205
+ return True
206
+
207
+ return False