griptape-nodes 0.65.5__py3-none-any.whl → 0.66.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 (60) hide show
  1. griptape_nodes/common/node_executor.py +352 -27
  2. griptape_nodes/drivers/storage/base_storage_driver.py +12 -3
  3. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +18 -2
  4. griptape_nodes/drivers/storage/local_storage_driver.py +42 -5
  5. griptape_nodes/exe_types/base_iterative_nodes.py +0 -1
  6. griptape_nodes/exe_types/connections.py +42 -0
  7. griptape_nodes/exe_types/core_types.py +2 -2
  8. griptape_nodes/exe_types/node_groups/__init__.py +2 -1
  9. griptape_nodes/exe_types/node_groups/base_iterative_node_group.py +177 -0
  10. griptape_nodes/exe_types/node_groups/base_node_group.py +1 -0
  11. griptape_nodes/exe_types/node_groups/subflow_node_group.py +35 -2
  12. griptape_nodes/exe_types/param_types/parameter_audio.py +1 -1
  13. griptape_nodes/exe_types/param_types/parameter_bool.py +1 -1
  14. griptape_nodes/exe_types/param_types/parameter_button.py +1 -1
  15. griptape_nodes/exe_types/param_types/parameter_float.py +1 -1
  16. griptape_nodes/exe_types/param_types/parameter_image.py +1 -1
  17. griptape_nodes/exe_types/param_types/parameter_int.py +1 -1
  18. griptape_nodes/exe_types/param_types/parameter_number.py +1 -1
  19. griptape_nodes/exe_types/param_types/parameter_string.py +1 -1
  20. griptape_nodes/exe_types/param_types/parameter_three_d.py +1 -1
  21. griptape_nodes/exe_types/param_types/parameter_video.py +1 -1
  22. griptape_nodes/machines/control_flow.py +5 -4
  23. griptape_nodes/machines/dag_builder.py +121 -55
  24. griptape_nodes/machines/fsm.py +10 -0
  25. griptape_nodes/machines/parallel_resolution.py +39 -38
  26. griptape_nodes/machines/sequential_resolution.py +29 -3
  27. griptape_nodes/node_library/library_registry.py +41 -2
  28. griptape_nodes/retained_mode/events/library_events.py +147 -8
  29. griptape_nodes/retained_mode/events/os_events.py +12 -4
  30. griptape_nodes/retained_mode/managers/fitness_problems/libraries/__init__.py +2 -0
  31. griptape_nodes/retained_mode/managers/fitness_problems/libraries/incompatible_requirements_problem.py +34 -0
  32. griptape_nodes/retained_mode/managers/flow_manager.py +133 -20
  33. griptape_nodes/retained_mode/managers/library_manager.py +1324 -564
  34. griptape_nodes/retained_mode/managers/node_manager.py +9 -3
  35. griptape_nodes/retained_mode/managers/os_manager.py +429 -65
  36. griptape_nodes/retained_mode/managers/resource_types/compute_resource.py +82 -0
  37. griptape_nodes/retained_mode/managers/resource_types/os_resource.py +17 -0
  38. griptape_nodes/retained_mode/managers/static_files_manager.py +21 -8
  39. griptape_nodes/retained_mode/managers/version_compatibility_manager.py +3 -3
  40. griptape_nodes/version_compatibility/versions/v0_39_0/modified_parameters_set_removal.py +5 -5
  41. griptape_nodes/version_compatibility/versions/v0_65_4/__init__.py +5 -0
  42. griptape_nodes/version_compatibility/versions/v0_65_4/run_in_parallel_to_run_in_order.py +79 -0
  43. griptape_nodes/version_compatibility/versions/v0_65_5/__init__.py +5 -0
  44. griptape_nodes/version_compatibility/versions/v0_65_5/flux_2_removed_parameters.py +85 -0
  45. {griptape_nodes-0.65.5.dist-info → griptape_nodes-0.66.0.dist-info}/METADATA +1 -1
  46. {griptape_nodes-0.65.5.dist-info → griptape_nodes-0.66.0.dist-info}/RECORD +48 -53
  47. griptape_nodes/retained_mode/managers/library_lifecycle/__init__.py +0 -45
  48. griptape_nodes/retained_mode/managers/library_lifecycle/data_models.py +0 -191
  49. griptape_nodes/retained_mode/managers/library_lifecycle/library_directory.py +0 -346
  50. griptape_nodes/retained_mode/managers/library_lifecycle/library_fsm.py +0 -439
  51. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/__init__.py +0 -17
  52. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/base.py +0 -82
  53. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/github.py +0 -116
  54. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/local_file.py +0 -367
  55. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/package.py +0 -104
  56. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/sandbox.py +0 -155
  57. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance.py +0 -18
  58. griptape_nodes/retained_mode/managers/library_lifecycle/library_status.py +0 -12
  59. {griptape_nodes-0.65.5.dist-info → griptape_nodes-0.66.0.dist-info}/WHEEL +0 -0
  60. {griptape_nodes-0.65.5.dist-info → griptape_nodes-0.66.0.dist-info}/entry_points.txt +0 -0
@@ -1,12 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import copy
3
4
  import logging
4
5
  from dataclasses import dataclass, field
5
6
  from enum import StrEnum
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  from griptape_nodes.common.directed_graph import DirectedGraph
9
- from griptape_nodes.exe_types.base_iterative_nodes import BaseIterativeStartNode
10
+ from griptape_nodes.exe_types.base_iterative_nodes import BaseIterativeEndNode, BaseIterativeStartNode
11
+ from griptape_nodes.exe_types.connections import Direction
10
12
  from griptape_nodes.exe_types.core_types import ParameterTypeBuiltin
11
13
  from griptape_nodes.exe_types.node_types import NodeResolutionState
12
14
 
@@ -45,7 +47,7 @@ class DagBuilder:
45
47
  graphs: dict[str, DirectedGraph] # Str is the name of the start node associated here.
46
48
  node_to_reference: dict[str, DagNode]
47
49
  graph_to_nodes: dict[str, set[str]] # Track which nodes belong to which graph
48
- start_node_candidates: dict[str, set[str]]
50
+ start_node_candidates: dict[str, dict[str, set[str]]] # {data_node: {graph: {boundary_nodes}}}
49
51
 
50
52
  def __init__(self) -> None:
51
53
  self.graphs = {}
@@ -53,8 +55,31 @@ class DagBuilder:
53
55
  self.graph_to_nodes = {}
54
56
  self.start_node_candidates = {}
55
57
 
58
+ def _get_nodes_to_exclude_for_iterative_end(self, node: BaseNode, connections: Connections) -> set[str]:
59
+ """Get nodes to exclude when collecting dependencies for BaseIterativeEndNode.
60
+
61
+ Args:
62
+ node: The node being processed
63
+ connections: The connections manager
64
+
65
+ Returns:
66
+ Set of node names to exclude (empty if not BaseIterativeEndNode)
67
+
68
+ Raises:
69
+ ValueError: If node is BaseIterativeEndNode but start_node is None
70
+ """
71
+ if not isinstance(node, BaseIterativeEndNode):
72
+ return set()
73
+
74
+ if node.start_node is None:
75
+ error_msg = f"Error: {node.name} is not properly connected to a start node"
76
+ logger.error(error_msg)
77
+ raise ValueError(error_msg)
78
+
79
+ return self.collect_loop_body_nodes(node.start_node, node, connections)
80
+
56
81
  # Complex with the inner recursive method, but it needs connections and added_nodes.
57
- def add_node_with_dependencies(self, node: BaseNode, graph_name: str = "default") -> list[BaseNode]:
82
+ def add_node_with_dependencies(self, node: BaseNode, graph_name: str = "default") -> list[BaseNode]: # noqa: C901
58
83
  """Add node and all its dependencies to DAG. Returns list of added nodes."""
59
84
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
60
85
 
@@ -66,6 +91,9 @@ class DagBuilder:
66
91
  self.graphs[graph_name] = graph
67
92
  self.graph_to_nodes[graph_name] = set()
68
93
 
94
+ # Get nodes to exclude for BaseIterativeEndNode (loop body nodes)
95
+ nodes_to_exclude = self._get_nodes_to_exclude_for_iterative_end(node, connections)
96
+
69
97
  def _add_node_recursive(current_node: BaseNode, visited: set[str], graph: DirectedGraph) -> None:
70
98
  # Skip if already visited or already in DAG
71
99
  if current_node.name in visited:
@@ -102,6 +130,10 @@ class DagBuilder:
102
130
 
103
131
  upstream_node, _ = upstream_connection
104
132
 
133
+ # Skip nodes in exclusion set (for BaseIterativeEndNode loop body)
134
+ if upstream_node.name in nodes_to_exclude:
135
+ continue
136
+
105
137
  # Skip already resolved nodes
106
138
  if upstream_node.state == NodeResolutionState.RESOLVED:
107
139
  continue
@@ -114,6 +146,14 @@ class DagBuilder:
114
146
 
115
147
  _add_node_recursive(node, set(), graph)
116
148
 
149
+ # Special handling for BaseIterativeEndNode: add start_node as dependency
150
+ if isinstance(node, BaseIterativeEndNode) and node.start_node is not None:
151
+ # Add start_node and its dependencies
152
+ _add_node_recursive(node.start_node, set(), graph)
153
+ # Add edge from start_node to end_node
154
+ if node.start_node.name in graph.nodes() and node.name in graph.nodes():
155
+ graph.add_edge(node.start_node.name, node.name)
156
+
117
157
  return added_nodes
118
158
 
119
159
  def add_node(self, node: BaseNode, graph_name: str = "default") -> DagNode:
@@ -171,8 +211,15 @@ class DagBuilder:
171
211
  if root_node == node.node_reference:
172
212
  continue
173
213
 
214
+ # Skip if the root node is the end node of a start node. It's technically not downstream.
215
+ if (
216
+ isinstance(node.node_reference, BaseIterativeStartNode)
217
+ and root_node == node.node_reference.end_node
218
+ ):
219
+ continue
220
+
174
221
  # Check if the target node is in the forward path from this root
175
- if self._is_node_in_forward_path(root_node, node.node_reference, connections):
222
+ if connections.is_node_in_forward_control_path(root_node, node.node_reference):
176
223
  return False # This graph could still reach the target node
177
224
 
178
225
  # Otherwise, return true at the end of the function
@@ -250,6 +297,49 @@ class DagBuilder:
250
297
 
251
298
  return nodes_in_path
252
299
 
300
+ @staticmethod
301
+ def collect_loop_body_nodes(
302
+ start_node: BaseIterativeStartNode, end_node: BaseIterativeEndNode, connections: Connections
303
+ ) -> set[str]:
304
+ """Collect all nodes in the loop body between start_node and end_node.
305
+
306
+ This walks through all outgoing connections from start_node and stops when
307
+ reaching end_node, collecting all intermediate nodes.
308
+
309
+ Args:
310
+ start_node: The iterative start node
311
+ end_node: The iterative end node
312
+ connections: The connections manager
313
+
314
+ Returns:
315
+ Set of node names in the loop body (between start and end, exclusive)
316
+ """
317
+ loop_body_nodes: set[str] = set()
318
+ to_visit = []
319
+ to_visit.append(start_node)
320
+ visited = set()
321
+ while to_visit:
322
+ current_node = to_visit.pop(0)
323
+
324
+ if current_node.name in visited:
325
+ continue
326
+ visited.add(current_node.name)
327
+ # Don't add start or end nodes themselves
328
+ if current_node not in (start_node, end_node):
329
+ loop_body_nodes.add(current_node.name)
330
+ # Stop traversal if we've reached the end node
331
+ if current_node == end_node:
332
+ continue
333
+ for param in current_node.parameters:
334
+ downstream_connection = connections.get_connected_node(
335
+ current_node, param, direction=Direction.DOWNSTREAM, include_internal=False
336
+ )
337
+ if downstream_connection:
338
+ next_node = downstream_connection.node
339
+ if next_node.name not in visited:
340
+ to_visit.append(next_node)
341
+ return loop_body_nodes
342
+
253
343
  @staticmethod
254
344
  def collect_data_dependencies_for_node(
255
345
  node: BaseNode, connections: Connections, nodes_to_exclude: set[str], visited: set[str]
@@ -302,39 +392,6 @@ class DagBuilder:
302
392
 
303
393
  return dependencies
304
394
 
305
- def _is_node_in_forward_path(
306
- self, start_node: BaseNode, target_node: BaseNode, connections: Connections, visited: set[str] | None = None
307
- ) -> bool:
308
- """Check if target_node is reachable from start_node through control flow connections."""
309
- if visited is None:
310
- visited = set()
311
-
312
- if start_node.name in visited:
313
- return False
314
- visited.add(start_node.name)
315
-
316
- # Check ALL outgoing control connections, not just get_next_control_output()
317
- # This handles IfElse nodes that have multiple possible control outputs
318
- if start_node.name in connections.outgoing_index:
319
- for param_name, connection_ids in connections.outgoing_index[start_node.name].items():
320
- # Find the parameter to check if it's a control type
321
- param = start_node.get_parameter_by_name(param_name)
322
- if param and param.output_type == ParameterTypeBuiltin.CONTROL_TYPE.value:
323
- # This is a control parameter - check all its connections
324
- for connection_id in connection_ids:
325
- if connection_id in connections.connections:
326
- connection = connections.connections[connection_id]
327
- next_node = connection.target_node
328
-
329
- if next_node.name == target_node.name:
330
- return True
331
-
332
- # Recursively check the forward path
333
- if self._is_node_in_forward_path(next_node, target_node, connections, visited):
334
- return True
335
-
336
- return False
337
-
338
395
  def cleanup_empty_graph_nodes(self, graph_name: str) -> None:
339
396
  """Remove nodes from node_to_reference when their graph becomes empty (only in single node resolution)."""
340
397
  if graph_name in self.graph_to_nodes:
@@ -342,21 +399,30 @@ class DagBuilder:
342
399
  self.node_to_reference.pop(node_name, None)
343
400
  self.graph_to_nodes.pop(graph_name, None)
344
401
 
345
- def remove_graph_from_dependencies(self) -> list[str]:
346
- # Check all start node candidates and return those whose dependent graphs are all empty
347
- start_nodes = []
348
- # copy because we will be removing as iterating.
349
- for start_node_name, graph_deps in self.start_node_candidates.copy().items():
350
- # Check if all graphs this start node depends on are now empty
351
- all_deps_empty = True
352
- for graph_deps_name in graph_deps:
353
- # Check if this graph exists and has nodes
354
- if graph_deps_name in self.graphs and len(self.graphs[graph_deps_name].nodes()) > 0:
355
- all_deps_empty = False
356
- break
357
-
358
- # If all dependent graphs are empty, this start node can be queued
359
- if all_deps_empty:
360
- del self.start_node_candidates[start_node_name]
361
- start_nodes.append(start_node_name)
362
- return start_nodes
402
+ def remove_node_from_dependencies(self, completed_node: str, graph_name: str) -> list[str]:
403
+ """Remove completed node from all dependencies, return nodes ready to execute.
404
+
405
+ Args:
406
+ completed_node: Name of the node that just completed
407
+ graph_name: Name of the graph the node completed in
408
+
409
+ Returns:
410
+ List of data node names that are now ready to execute
411
+ """
412
+ newly_available = []
413
+
414
+ for data_node, graph_deps in copy.deepcopy(list(self.start_node_candidates.items())):
415
+ if graph_name in graph_deps:
416
+ # Remove the completed node from this graph's boundary nodes
417
+ graph_deps[graph_name].discard(completed_node)
418
+
419
+ # If all boundary nodes from this graph have completed, remove the graph dependency
420
+ if len(graph_deps[graph_name]) == 0:
421
+ del graph_deps[graph_name]
422
+
423
+ # If all graph dependencies are satisfied, the data node is ready
424
+ if len(graph_deps) == 0:
425
+ del self.start_node_candidates[data_node]
426
+ newly_available.append(data_node)
427
+
428
+ return newly_available
@@ -1,8 +1,18 @@
1
+ from enum import StrEnum
1
2
  from typing import Any, TypeVar
2
3
 
3
4
  T = TypeVar("T")
4
5
 
5
6
 
7
+ class WorkflowState(StrEnum):
8
+ """Workflow execution states."""
9
+
10
+ NO_ERROR = "no_error"
11
+ WORKFLOW_COMPLETE = "workflow_complete"
12
+ ERRORED = "errored"
13
+ CANCELED = "canceled"
14
+
15
+
6
16
  class State:
7
17
  @staticmethod
8
18
  async def on_enter(context: Any) -> type["State"] | None: # noqa: ARG004
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- from enum import StrEnum
6
5
  from typing import TYPE_CHECKING
7
6
 
8
7
  from griptape_nodes.exe_types.base_iterative_nodes import BaseIterativeEndNode, BaseIterativeStartNode
@@ -14,7 +13,7 @@ from griptape_nodes.exe_types.node_types import (
14
13
  )
15
14
  from griptape_nodes.exe_types.type_validator import TypeValidator
16
15
  from griptape_nodes.machines.dag_builder import NodeState
17
- from griptape_nodes.machines.fsm import FSM, State
16
+ from griptape_nodes.machines.fsm import FSM, State, WorkflowState
18
17
  from griptape_nodes.node_library.library_registry import LibraryRegistry
19
18
  from griptape_nodes.retained_mode.events.base_events import (
20
19
  ExecutionEvent,
@@ -41,15 +40,6 @@ if TYPE_CHECKING:
41
40
  logger = logging.getLogger("griptape_nodes")
42
41
 
43
42
 
44
- class WorkflowState(StrEnum):
45
- """Workflow execution states."""
46
-
47
- NO_ERROR = "no_error"
48
- WORKFLOW_COMPLETE = "workflow_complete"
49
- ERRORED = "errored"
50
- CANCELED = "canceled"
51
-
52
-
53
43
  class ParallelResolutionContext:
54
44
  paused: bool
55
45
  flow_name: str
@@ -60,7 +50,6 @@ class ParallelResolutionContext:
60
50
  task_to_node: dict[asyncio.Task, DagNode]
61
51
  dag_builder: DagBuilder | None
62
52
  last_resolved_node: BaseNode | None # Track the last node that was resolved
63
- last_resolved_node: BaseNode | None # Track the last node that was resolved
64
53
 
65
54
  def __init__(
66
55
  self, flow_name: str, max_nodes_in_parallel: int | None = None, dag_builder: DagBuilder | None = None
@@ -116,7 +105,21 @@ class ParallelResolutionContext:
116
105
 
117
106
  class ExecuteDagState(State):
118
107
  @staticmethod
119
- async def handle_done_nodes(context: ParallelResolutionContext, done_node: DagNode, network_name: str) -> None: # noqa: C901
108
+ def check_for_new_start_nodes(
109
+ context: ParallelResolutionContext, current_node_name: str, network_name: str
110
+ ) -> None:
111
+ # Remove this node from dependencies and get newly available nodes
112
+ if context.dag_builder is not None:
113
+ newly_available = context.dag_builder.remove_node_from_dependencies(current_node_name, network_name)
114
+ for data_node_name in newly_available:
115
+ data_node = GriptapeNodes.NodeManager().get_node_by_name(data_node_name)
116
+ added_nodes = context.dag_builder.add_node_with_dependencies(data_node, data_node_name)
117
+ if added_nodes:
118
+ for added_node in added_nodes:
119
+ ExecuteDagState._try_queue_waiting_node(context, added_node.name)
120
+
121
+ @staticmethod
122
+ async def handle_done_nodes(context: ParallelResolutionContext, done_node: DagNode, network_name: str) -> None:
120
123
  current_node = done_node.node_reference
121
124
 
122
125
  # Check if node was already resolved (shouldn't happen)
@@ -194,13 +197,7 @@ class ExecuteDagState(State):
194
197
  )
195
198
  # Now the final thing to do, is to take their directed graph and update it.
196
199
  ExecuteDagState.get_next_control_graph(context, current_node, network_name)
197
- graph = context.networks[network_name]
198
- if len(graph.nodes()) == 0 and context.dag_builder is not None:
199
- # remove from dependencies. This is so we can potentially queue the data node.
200
- data_start_nodes = context.dag_builder.remove_graph_from_dependencies()
201
- for data_start_node_name in data_start_nodes:
202
- data_start_node = GriptapeNodes.NodeManager().get_node_by_name(data_start_node_name)
203
- context.dag_builder.add_node_with_dependencies(data_start_node, network_name)
200
+ ExecuteDagState.check_for_new_start_nodes(context, current_node.name, network_name)
204
201
 
205
202
  @staticmethod
206
203
  def get_next_control_graph(context: ParallelResolutionContext, node: BaseNode, network_name: str) -> None:
@@ -227,7 +224,8 @@ class ExecuteDagState(State):
227
224
  if network is None:
228
225
  msg = f"Network {network_name} not found in DAG builder"
229
226
  raise ValueError(msg)
230
- if flow_manager.global_single_node_resolution:
227
+ is_isolated = context.dag_builder is not flow_manager.global_dag_builder
228
+ if flow_manager.global_single_node_resolution and not is_isolated:
231
229
  # Clean up nodes from emptied graphs in single node resolution mode
232
230
  if len(network) == 0 and context.dag_builder is not None:
233
231
  context.dag_builder.cleanup_empty_graph_nodes(network_name)
@@ -354,7 +352,7 @@ class ExecuteDagState(State):
354
352
  )
355
353
  )
356
354
  if isinstance(result, SetParameterValueResultFailure):
357
- msg = f"Failed to set value for parameter '{parameter.name}' on node '{current_node.name}': {result.result_details}"
355
+ msg = f"Failed to set parameter value for node '{current_node.name}' and parameter '{parameter.name}'. Details: {result.result_details}"
358
356
  logger.error(msg)
359
357
  raise RuntimeError(msg)
360
358
 
@@ -390,7 +388,9 @@ class ExecuteDagState(State):
390
388
  networks = context.networks
391
389
  handled_nodes = set() # Track nodes we've already processed to avoid duplicates
392
390
 
393
- for network_name, network in networks.items():
391
+ # Create a copy of items to avoid "dictionary changed size during iteration" error
392
+ # This is necessary because handle_done_nodes can add new networks via the DAG builder
393
+ for network_name, network in list(networks.items()):
394
394
  # Check and see if there are leaf nodes that are cancelled.
395
395
  # Reinitialize leaf nodes since maybe we changed things up.
396
396
  # We removed nodes from the network. There may be new leaf nodes.
@@ -481,8 +481,10 @@ class ExecuteDagState(State):
481
481
  node_reference.node_reference.parameter_output_values.silent_clear()
482
482
  exceptions = node_reference.node_reference.validate_before_node_run()
483
483
  if exceptions:
484
- msg = f"Canceling flow run. Node '{node_reference.node_reference.name}' encountered problems: {exceptions}"
485
- logger.error(msg)
484
+ msg = f"Node '{node_reference.node_reference.name}' encountered problems: {exceptions}"
485
+ logger.error("Canceling flow run. %s", msg)
486
+ context.error_message = msg
487
+ context.workflow_state = WorkflowState.ERRORED
486
488
  return ErrorState
487
489
 
488
490
  # We've set up the node for success completely. Now we check and handle accordingly if it's a for-each-start node
@@ -497,6 +499,8 @@ class ExecuteDagState(State):
497
499
  f"Cannot have a Start Loop Node without an End Loop Node: {node_reference.node_reference.name}"
498
500
  )
499
501
  logger.error(msg)
502
+ context.error_message = msg
503
+ context.workflow_state = WorkflowState.ERRORED
500
504
  return ErrorState
501
505
  # We're going to skip straight to the end node here instead.
502
506
  # Set end node to node reference
@@ -551,18 +555,12 @@ class ExecuteDagState(State):
551
555
  exc = task.exception()
552
556
  dag_node = context.task_to_node.get(task)
553
557
  node_name = dag_node.node_reference.name if dag_node else "Unknown"
554
- node_type = dag_node.node_reference.__class__.__name__ if dag_node else "Unknown"
555
-
556
- logger.exception(
557
- "Task execution failed for node '%s' (type: %s) in flow '%s'. Exception: %s",
558
- node_name,
559
- node_type,
560
- context.flow_name,
561
- exc,
562
- )
558
+
559
+ logger.exception("Error processing node '%s'", node_name)
560
+ msg = f"Node '{node_name}' encountered a problem: {exc}"
563
561
 
564
562
  context.task_to_node.pop(task)
565
- context.error_message = f"Task execution failed for node '{node_name}': {exc}"
563
+ context.error_message = msg
566
564
  context.workflow_state = WorkflowState.ERRORED
567
565
  return ErrorState
568
566
  context.task_to_node.pop(task)
@@ -578,9 +576,6 @@ class ExecuteDagState(State):
578
576
  class ErrorState(State):
579
577
  @staticmethod
580
578
  async def on_enter(context: ParallelResolutionContext) -> type[State] | None:
581
- if context.error_message:
582
- logger.error("DAG execution error: %s", context.error_message)
583
-
584
579
  for node in context.node_to_reference.values():
585
580
  # Cancel all nodes that haven't yet begun processing.
586
581
  if node.node_state == NodeState.QUEUED:
@@ -676,3 +671,9 @@ class ParallelResolutionMachine(FSM[ParallelResolutionContext]):
676
671
  def get_last_resolved_node(self) -> BaseNode | None:
677
672
  """Get the last node that was resolved in the DAG execution."""
678
673
  return self._context.last_resolved_node
674
+
675
+ def is_errored(self) -> bool:
676
+ return self._context.workflow_state == WorkflowState.ERRORED
677
+
678
+ def get_error_message(self) -> str | None:
679
+ return self._context.error_message
@@ -9,7 +9,7 @@ from griptape_nodes.exe_types.connections import Direction
9
9
  from griptape_nodes.exe_types.core_types import ParameterTypeBuiltin
10
10
  from griptape_nodes.exe_types.node_types import BaseNode, NodeResolutionState
11
11
  from griptape_nodes.exe_types.type_validator import TypeValidator
12
- from griptape_nodes.machines.fsm import FSM, State
12
+ from griptape_nodes.machines.fsm import FSM, State, WorkflowState
13
13
  from griptape_nodes.node_library.library_registry import LibraryRegistry
14
14
  from griptape_nodes.retained_mode.events.base_events import (
15
15
  ExecutionEvent,
@@ -42,10 +42,14 @@ class Focus:
42
42
  class ResolutionContext:
43
43
  focus_stack: list[Focus]
44
44
  paused: bool
45
+ error_message: str | None
46
+ workflow_state: WorkflowState
45
47
 
46
48
  def __init__(self) -> None:
47
49
  self.focus_stack = []
48
50
  self.paused = False
51
+ self.error_message = None
52
+ self.workflow_state = WorkflowState.NO_ERROR
49
53
 
50
54
  @property
51
55
  def current_node(self) -> BaseNode:
@@ -62,6 +66,8 @@ class ResolutionContext:
62
66
  node.clear_node()
63
67
  self.focus_stack.clear()
64
68
  self.paused = False
69
+ self.error_message = None
70
+ self.workflow_state = WorkflowState.NO_ERROR
65
71
 
66
72
 
67
73
  class InitializeSpotlightState(State):
@@ -260,7 +266,10 @@ class ExecuteNodeState(State):
260
266
 
261
267
  exceptions = current_node.validate_before_node_run()
262
268
  if exceptions:
263
- msg = f"Canceling flow run. Node '{current_node.name}' encountered problems: {exceptions}"
269
+ msg = f"Node '{current_node.name}' encountered problems: {exceptions}"
270
+ logger.error("Canceling flow run. %s", msg)
271
+ context.error_message = msg
272
+ context.workflow_state = WorkflowState.ERRORED
264
273
  # Mark the node as unresolved, broadcasting to everyone.
265
274
  raise RuntimeError(msg)
266
275
  if not context.paused:
@@ -268,7 +277,7 @@ class ExecuteNodeState(State):
268
277
  return None
269
278
 
270
279
  @staticmethod
271
- async def on_update(context: ResolutionContext) -> type[State] | None:
280
+ async def on_update(context: ResolutionContext) -> type[State] | None: # noqa: PLR0915
272
281
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
273
282
 
274
283
  # Once everything has been set
@@ -305,10 +314,21 @@ class ExecuteNodeState(State):
305
314
  wrapped_event=ExecutionEvent(payload=NodeFinishProcessEvent(node_name=current_node.name))
306
315
  )
307
316
  )
317
+ msg = f"Node '{current_node.name}' encountered a problem that cancelled the task."
318
+ context.error_message = msg
319
+ context.workflow_state = WorkflowState.ERRORED
320
+ # Mark the node as unresolved, broadcasting to everyone.
321
+ current_node.make_node_unresolved(
322
+ current_states_to_trigger_change_event=set(
323
+ {NodeResolutionState.UNRESOLVED, NodeResolutionState.RESOLVED, NodeResolutionState.RESOLVING}
324
+ )
325
+ )
308
326
  return CompleteState
309
327
  except Exception as e:
310
328
  logger.exception("Error processing node '%s", current_node.name)
311
329
  msg = f"Node '{current_node.name}' encountered a problem: {e}"
330
+ context.error_message = msg
331
+ context.workflow_state = WorkflowState.ERRORED
312
332
  # Mark the node as unresolved, broadcasting to everyone.
313
333
  current_node.make_node_unresolved(
314
334
  current_states_to_trigger_change_event=set(
@@ -449,6 +469,12 @@ class SequentialResolutionMachine(FSM[ResolutionContext]):
449
469
  def is_started(self) -> bool:
450
470
  return self._current_state is not None
451
471
 
472
+ def is_errored(self) -> bool:
473
+ return self._context.workflow_state == WorkflowState.ERRORED
474
+
475
+ def get_error_message(self) -> str | None:
476
+ return self._context.error_message
477
+
452
478
  # Unused argument but necessary for parallel_resolution because of futures ending during cancel but not reset.
453
479
  def reset_machine(self, *, cancel: bool = False) -> None: # noqa: ARG002
454
480
  self._context.reset()
@@ -3,11 +3,14 @@ from __future__ import annotations
3
3
  import logging
4
4
  from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
5
5
 
6
- from pydantic import BaseModel, Field
6
+ from pydantic import BaseModel, Field, field_validator
7
7
 
8
8
  from griptape_nodes.retained_mode.managers.fitness_problems.libraries.duplicate_node_registration_problem import (
9
9
  DuplicateNodeRegistrationProblem,
10
10
  )
11
+ from griptape_nodes.retained_mode.managers.resource_components.resource_instance import (
12
+ Requirements, # noqa: TC001 (putting this into type checking causes it to not be defined for Pydantic field_validator)
13
+ )
11
14
  from griptape_nodes.utils.metaclasses import SingletonMeta
12
15
 
13
16
  if TYPE_CHECKING:
@@ -34,6 +37,40 @@ class Dependencies(BaseModel):
34
37
  pip_install_flags: list[str] | None = None
35
38
 
36
39
 
40
+ class ResourceRequirements(BaseModel):
41
+ """Resource requirements for a library.
42
+
43
+ Specifies what system resources (OS, compute backends) the library needs.
44
+ Example: {"platform": (["linux", "windows"], "has_any"), "arch": "x86_64", "compute": (["cuda", "cpu"], "has_all")}
45
+ """
46
+
47
+ required: Requirements | None = None
48
+
49
+ @field_validator("required", mode="before")
50
+ @classmethod
51
+ def convert_lists_to_tuples(cls, v: Any) -> Any:
52
+ """Convert list values to tuples for requirements loaded from JSON.
53
+
54
+ JSON arrays become Python lists, but the Requirements type expects tuples
55
+ for (value, comparator) pairs.
56
+ """
57
+ if v is None:
58
+ return None
59
+
60
+ if not isinstance(v, dict):
61
+ return v
62
+
63
+ converted = {}
64
+ comparator_tuple_length = 2
65
+ for key, value in v.items():
66
+ # Check if value is a list with exactly 2 elements where second is a string (comparator)
67
+ if isinstance(value, list) and len(value) == comparator_tuple_length and isinstance(value[1], str):
68
+ converted[key] = tuple(value)
69
+ else:
70
+ converted[key] = value
71
+ return converted
72
+
73
+
37
74
  class LibraryMetadata(BaseModel):
38
75
  """Metadata that explains details about the library, including versioning and search details."""
39
76
 
@@ -45,6 +82,8 @@ class LibraryMetadata(BaseModel):
45
82
  dependencies: Dependencies | None = None
46
83
  # If True, this library will be surfaced to Griptape Nodes customers when listing Node Libraries available to them.
47
84
  is_griptape_nodes_searchable: bool = True
85
+ # Resource requirements for this library. If None, library is assumed to work on any platform.
86
+ resources: ResourceRequirements | None = None
48
87
 
49
88
 
50
89
  class IconVariant(BaseModel):
@@ -112,7 +151,7 @@ class LibrarySchema(BaseModel):
112
151
  library itself.
113
152
  """
114
153
 
115
- LATEST_SCHEMA_VERSION: ClassVar[str] = "0.3.0"
154
+ LATEST_SCHEMA_VERSION: ClassVar[str] = "0.4.0"
116
155
 
117
156
  name: str
118
157
  library_schema_version: str