griptape-nodes 0.57.1__py3-none-any.whl → 0.58.1__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 (51) hide show
  1. griptape_nodes/api_client/__init__.py +9 -0
  2. griptape_nodes/api_client/client.py +279 -0
  3. griptape_nodes/api_client/request_client.py +273 -0
  4. griptape_nodes/app/app.py +57 -150
  5. griptape_nodes/bootstrap/utils/python_subprocess_executor.py +1 -1
  6. griptape_nodes/bootstrap/workflow_executors/local_session_workflow_executor.py +22 -50
  7. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +6 -1
  8. griptape_nodes/bootstrap/workflow_executors/subprocess_workflow_executor.py +27 -46
  9. griptape_nodes/bootstrap/workflow_executors/utils/subprocess_script.py +7 -0
  10. griptape_nodes/bootstrap/workflow_publishers/local_workflow_publisher.py +3 -1
  11. griptape_nodes/bootstrap/workflow_publishers/subprocess_workflow_publisher.py +3 -1
  12. griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +16 -1
  13. griptape_nodes/common/node_executor.py +466 -0
  14. griptape_nodes/drivers/storage/base_storage_driver.py +0 -11
  15. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +7 -25
  16. griptape_nodes/drivers/storage/local_storage_driver.py +2 -2
  17. griptape_nodes/exe_types/connections.py +37 -9
  18. griptape_nodes/exe_types/core_types.py +1 -1
  19. griptape_nodes/exe_types/node_types.py +115 -22
  20. griptape_nodes/machines/control_flow.py +48 -7
  21. griptape_nodes/machines/parallel_resolution.py +98 -29
  22. griptape_nodes/machines/sequential_resolution.py +61 -22
  23. griptape_nodes/node_library/library_registry.py +24 -1
  24. griptape_nodes/node_library/workflow_registry.py +38 -2
  25. griptape_nodes/retained_mode/events/execution_events.py +8 -1
  26. griptape_nodes/retained_mode/events/flow_events.py +90 -3
  27. griptape_nodes/retained_mode/events/node_events.py +17 -10
  28. griptape_nodes/retained_mode/events/workflow_events.py +5 -0
  29. griptape_nodes/retained_mode/griptape_nodes.py +16 -219
  30. griptape_nodes/retained_mode/managers/config_manager.py +0 -46
  31. griptape_nodes/retained_mode/managers/engine_identity_manager.py +225 -74
  32. griptape_nodes/retained_mode/managers/flow_manager.py +1276 -230
  33. griptape_nodes/retained_mode/managers/library_manager.py +7 -8
  34. griptape_nodes/retained_mode/managers/node_manager.py +197 -9
  35. griptape_nodes/retained_mode/managers/secrets_manager.py +26 -0
  36. griptape_nodes/retained_mode/managers/session_manager.py +264 -227
  37. griptape_nodes/retained_mode/managers/settings.py +4 -38
  38. griptape_nodes/retained_mode/managers/static_files_manager.py +3 -3
  39. griptape_nodes/retained_mode/managers/version_compatibility_manager.py +135 -6
  40. griptape_nodes/retained_mode/managers/workflow_manager.py +206 -78
  41. griptape_nodes/servers/mcp.py +23 -15
  42. griptape_nodes/utils/async_utils.py +36 -0
  43. griptape_nodes/utils/dict_utils.py +8 -2
  44. griptape_nodes/version_compatibility/versions/v0_39_0/modified_parameters_set_removal.py +11 -6
  45. griptape_nodes/version_compatibility/workflow_versions/v0_7_0/local_executor_argument_addition.py +12 -5
  46. {griptape_nodes-0.57.1.dist-info → griptape_nodes-0.58.1.dist-info}/METADATA +4 -3
  47. {griptape_nodes-0.57.1.dist-info → griptape_nodes-0.58.1.dist-info}/RECORD +49 -47
  48. {griptape_nodes-0.57.1.dist-info → griptape_nodes-0.58.1.dist-info}/WHEEL +1 -1
  49. griptape_nodes/retained_mode/utils/engine_identity.py +0 -245
  50. griptape_nodes/servers/ws_request_manager.py +0 -268
  51. {griptape_nodes-0.57.1.dist-info → griptape_nodes-0.58.1.dist-info}/entry_points.txt +0 -0
@@ -58,7 +58,7 @@ class LocalStorageDriver(BaseStorageDriver):
58
58
 
59
59
  def create_signed_download_url(self, path: Path) -> str:
60
60
  # The base_url already includes the /static path, so just append the path
61
- url = f"{self.base_url}/{path}"
61
+ url = f"{self.base_url}/{path.as_posix()}"
62
62
  # Add a cache-busting query parameter to the URL so that the browser always reloads the file
63
63
  cache_busted_url = f"{url}?t={int(time.time())}"
64
64
  return cache_busted_url
@@ -70,7 +70,7 @@ class LocalStorageDriver(BaseStorageDriver):
70
70
  path: The path of the file to delete.
71
71
  """
72
72
  # Use the static server's delete endpoint
73
- delete_url = urljoin(self.base_url, f"/static-files/{path}")
73
+ delete_url = urljoin(self.base_url, f"/static-files/{path.as_posix()}")
74
74
 
75
75
  try:
76
76
  response = httpx.delete(delete_url)
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  from dataclasses import dataclass
3
+ from enum import StrEnum
3
4
 
4
5
  from griptape_nodes.exe_types.core_types import Parameter, ParameterMode, ParameterTypeBuiltin
5
6
  from griptape_nodes.exe_types.node_types import BaseNode, Connection, EndLoopNode, NodeResolutionState, StartLoopNode
@@ -7,6 +8,11 @@ from griptape_nodes.exe_types.node_types import BaseNode, Connection, EndLoopNod
7
8
  logger = logging.getLogger("griptape_nodes")
8
9
 
9
10
 
11
+ class Direction(StrEnum):
12
+ UPSTREAM = "upstream"
13
+ DOWNSTREAM = "downstream"
14
+
15
+
10
16
  @dataclass
11
17
  class Connections:
12
18
  # store connections as IDs
@@ -127,28 +133,50 @@ class Connections:
127
133
  return connection.source_node, connection.source_parameter
128
134
  return None # No connection found for this control parameter
129
135
 
130
- def get_connected_node(self, node: BaseNode, parameter: Parameter) -> tuple[BaseNode, Parameter] | None:
136
+ def get_connected_node(
137
+ self, node: BaseNode, parameter: Parameter, direction: Direction | None = None
138
+ ) -> tuple[BaseNode, Parameter] | None:
131
139
  # Check to see if we should be getting the next connection or the previous connection based on the parameter.
132
140
  # Override this method for EndLoopNodes - these might have to go backwards or forwards.
133
- if isinstance(node, EndLoopNode) and ParameterTypeBuiltin.CONTROL_TYPE.value == parameter.output_type:
134
- return self._get_connected_node_for_end_loop_control(node, parameter)
135
- if ParameterTypeBuiltin.CONTROL_TYPE.value == parameter.output_type:
136
- connections = self.outgoing_index
141
+ if direction is not None:
142
+ # We've added direction as an override, since we sometimes need to get connections in a certain direction regardless of parameter types.
143
+ if direction == Direction.UPSTREAM:
144
+ connections = self.incoming_index
145
+ elif direction == Direction.DOWNSTREAM:
146
+ connections = self.outgoing_index
137
147
  else:
138
- connections = self.incoming_index
148
+ if isinstance(node, EndLoopNode) and ParameterTypeBuiltin.CONTROL_TYPE.value == parameter.output_type:
149
+ return self._get_connected_node_for_end_loop_control(node, parameter)
150
+ if ParameterTypeBuiltin.CONTROL_TYPE.value == parameter.output_type:
151
+ connections = self.outgoing_index
152
+ # We still default to downstream (forwards) connections for control parameters
153
+ direction = Direction.DOWNSTREAM
154
+ else:
155
+ connections = self.incoming_index
156
+ # And upstream (backwards) connections for data parameters.
157
+ direction = Direction.UPSTREAM
139
158
  connections_from_node = connections.get(node.name, {})
140
159
 
141
160
  connection_id = connections_from_node.get(parameter.name, [])
142
161
  # TODO: https://github.com/griptape-ai/griptape-nodes/issues/859
143
162
  if not len(connection_id):
144
163
  return None
145
- if len(connection_id) > 1:
146
- msg = "There should not be more than one connection here."
164
+ # Right now, our special case is that it is ok to have multiple inputs to a CONTROL_TYPE parameter, so if we're going upstream, it's ok. And it's ok to have multiple downstream outputs from a data type parameter.
165
+ if (
166
+ len(connection_id) > 1
167
+ and not (
168
+ direction == Direction.UPSTREAM and parameter.output_type == ParameterTypeBuiltin.CONTROL_TYPE.value
169
+ )
170
+ and not (
171
+ direction == Direction.DOWNSTREAM and parameter.output_type != ParameterTypeBuiltin.CONTROL_TYPE.value
172
+ )
173
+ ):
174
+ msg = f"There should not be more than one {direction} connection here to/from {node.name}.{parameter.name}"
147
175
  raise ValueError(msg)
148
176
  connection_id = connection_id[0]
149
177
  if connection_id in self.connections:
150
178
  connection = self.connections[connection_id]
151
- if ParameterTypeBuiltin.CONTROL_TYPE.value == parameter.output_type:
179
+ if direction == Direction.DOWNSTREAM:
152
180
  # Return the target (next place to go)
153
181
  return connection.target_node, connection.target_parameter
154
182
  # Return the source (next place to chain back to)
@@ -1313,7 +1313,7 @@ class ControlParameter(Parameter, ABC):
1313
1313
  super().__init__(
1314
1314
  type=ParameterTypeBuiltin.CONTROL_TYPE.value,
1315
1315
  default_value=None,
1316
- settable=False,
1316
+ settable=True,
1317
1317
  name=name,
1318
1318
  tooltip=tooltip,
1319
1319
  input_types=input_types,
@@ -1,10 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
4
3
  import logging
4
+ import threading
5
+ import warnings
5
6
  from abc import ABC, abstractmethod
6
7
  from collections.abc import Callable, Generator, Iterable
7
- from concurrent.futures import ThreadPoolExecutor
8
8
  from dataclasses import dataclass, field
9
9
  from enum import StrEnum, auto
10
10
  from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
@@ -31,16 +31,13 @@ from griptape_nodes.retained_mode.events.base_events import (
31
31
  ProgressEvent,
32
32
  RequestPayload,
33
33
  )
34
- from griptape_nodes.retained_mode.events.execution_events import (
35
- NodeUnresolvedEvent,
36
- ParameterValueUpdateEvent,
37
- )
38
34
  from griptape_nodes.retained_mode.events.parameter_events import (
39
35
  AddParameterToNodeRequest,
40
36
  RemoveElementEvent,
41
37
  RemoveParameterFromNodeRequest,
42
38
  )
43
39
  from griptape_nodes.traits.options import Options
40
+ from griptape_nodes.utils import async_utils
44
41
 
45
42
  if TYPE_CHECKING:
46
43
  from griptape_nodes.exe_types.core_types import NodeMessagePayload
@@ -53,6 +50,8 @@ T = TypeVar("T")
53
50
  AsyncResult = Generator[Callable[[], T], T]
54
51
 
55
52
  LOCAL_EXECUTION = "Local Execution"
53
+ PRIVATE_EXECUTION = "Private Execution"
54
+ CONTROL_INPUT_PARAMETER = "Control Input Selection"
56
55
 
57
56
 
58
57
  class ImportDependency(NamedTuple):
@@ -116,8 +115,8 @@ def get_library_names_with_publish_handlers() -> list[str]:
116
115
  library_manager = GriptapeNodes.LibraryManager()
117
116
  event_handlers = library_manager.get_registered_event_handlers(PublishWorkflowRequest)
118
117
 
119
- # Always include "local" as the first option
120
- library_names = [LOCAL_EXECUTION]
118
+ # Always include "local" and "private" as the first options
119
+ library_names = [LOCAL_EXECUTION, PRIVATE_EXECUTION]
121
120
 
122
121
  # Add all registered library names that can handle PublishWorkflowRequest
123
122
  library_names.extend(sorted(event_handlers.keys()))
@@ -131,17 +130,18 @@ class BaseNode(ABC):
131
130
  metadata: dict[Any, Any]
132
131
 
133
132
  # Node Context Fields
134
- state: NodeResolutionState
135
133
  current_spotlight_parameter: Parameter | None = None
136
134
  parameter_values: dict[str, Any]
137
135
  parameter_output_values: TrackedParameterOutputValues
138
136
  stop_flow: bool = False
139
137
  root_ui_element: BaseNodeElement
138
+ _state: NodeResolutionState
140
139
  _tracked_parameters: list[BaseNodeElement]
141
140
  _entry_control_parameter: Parameter | None = (
142
141
  None # The control input parameter used to enter this node during execution
143
142
  )
144
143
  lock: bool = False # When lock is true, the node is locked and can't be modified. When lock is false, the node is unlocked and can be modified.
144
+ _cancellation_requested: threading.Event # Event indicating if cancellation has been requested for this node
145
145
 
146
146
  @property
147
147
  def parameters(self) -> list[Parameter]:
@@ -157,7 +157,7 @@ class BaseNode(ABC):
157
157
  state: NodeResolutionState = NodeResolutionState.UNRESOLVED,
158
158
  ) -> None:
159
159
  self.name = name
160
- self.state = state
160
+ self._state = state
161
161
  if metadata is None:
162
162
  self.metadata = {}
163
163
  else:
@@ -169,6 +169,7 @@ class BaseNode(ABC):
169
169
  self.root_ui_element._node_context = self
170
170
  self.process_generator = None
171
171
  self._tracked_parameters = []
172
+ self._cancellation_requested = threading.Event()
172
173
  self.set_entry_control_parameter(None)
173
174
  self.execution_environment = Parameter(
174
175
  name="execution_environment",
@@ -181,6 +182,18 @@ class BaseNode(ABC):
181
182
  )
182
183
  self.add_parameter(self.execution_environment)
183
184
 
185
+ @property
186
+ def state(self) -> NodeResolutionState:
187
+ """Get the current resolution state of the node.
188
+
189
+ Existence as @property facilitates subclasses overriding the getter for dynamic/computed state.
190
+ """
191
+ return self._state
192
+
193
+ @state.setter
194
+ def state(self, new_state: NodeResolutionState) -> None:
195
+ self._state = new_state
196
+
184
197
  # This is gross and we need to have a universal pass on resolution state changes and emission of events. That's what this ticket does!
185
198
  # https://github.com/griptape-ai/griptape-nodes/issues/994
186
199
  def make_node_unresolved(self, current_states_to_trigger_change_event: set[NodeResolutionState] | None) -> None:
@@ -190,6 +203,7 @@ class BaseNode(ABC):
190
203
  if current_states_to_trigger_change_event is not None and self.state in current_states_to_trigger_change_event:
191
204
  # Trigger the change event.
192
205
  # Send an event to the GUI so it knows this node has changed resolution state.
206
+ from griptape_nodes.retained_mode.events.execution_events import NodeUnresolvedEvent
193
207
 
194
208
  GriptapeNodes.EventManager().put_event(
195
209
  ExecutionGriptapeNodeEvent(
@@ -210,6 +224,27 @@ class BaseNode(ABC):
210
224
  """
211
225
  self._entry_control_parameter = parameter
212
226
 
227
+ @property
228
+ def is_cancellation_requested(self) -> bool:
229
+ """Check if cancellation has been requested for this node.
230
+
231
+ Returns:
232
+ True if cancellation has been requested, False otherwise
233
+ """
234
+ return self._cancellation_requested.is_set()
235
+
236
+ def request_cancellation(self) -> None:
237
+ """Request cancellation of this node's execution.
238
+
239
+ Sets a flag that the node can check during long-running operations
240
+ to cooperatively cancel execution.
241
+ """
242
+ self._cancellation_requested.set()
243
+
244
+ def clear_cancellation(self) -> None:
245
+ """Clear the cancellation request flag."""
246
+ self._cancellation_requested.clear()
247
+
213
248
  def emit_parameter_changes(self) -> None:
214
249
  if self._tracked_parameters:
215
250
  for parameter in self._tracked_parameters:
@@ -816,20 +851,14 @@ class BaseNode(ABC):
816
851
  return
817
852
 
818
853
  if isinstance(result, Generator):
819
- # Handle generator pattern asynchronously using the same logic as before
820
- loop = asyncio.get_running_loop()
821
-
822
854
  try:
823
855
  # Start the generator
824
856
  func = next(result)
825
857
 
826
858
  while True:
827
- # Run callable in thread pool (preserving existing behavior)
828
- with ThreadPoolExecutor() as executor:
829
- future_result = await loop.run_in_executor(executor, func)
830
-
831
859
  # Send result back and get next callable
832
- func = result.send(future_result)
860
+ func_result = await async_utils.to_thread(func)
861
+ func = result.send(func_result)
833
862
 
834
863
  except StopIteration:
835
864
  # Generator is done
@@ -871,12 +900,26 @@ class BaseNode(ABC):
871
900
  def get_config_value(self, service: str, value: str) -> str:
872
901
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
873
902
 
903
+ warnings.warn(
904
+ "get_config_value() is deprecated. Use GriptapeNodes.SecretsManager().get_secret() for secrets/API keys "
905
+ "or GriptapeNodes.ConfigManager().get_config_value() for other config values.",
906
+ UserWarning,
907
+ stacklevel=2,
908
+ )
909
+
874
910
  config_value = GriptapeNodes.ConfigManager().get_config_value(f"nodes.{service}.{value}")
875
911
  return config_value
876
912
 
877
913
  def set_config_value(self, service: str, value: str, new_value: str) -> None:
878
914
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
879
915
 
916
+ warnings.warn(
917
+ "set_config_value() is deprecated. Use GriptapeNodes.SecretsManager().set_secret() for secrets/API keys "
918
+ "or GriptapeNodes.ConfigManager().set_config_value() for other config values.",
919
+ UserWarning,
920
+ stacklevel=2,
921
+ )
922
+
880
923
  GriptapeNodes.ConfigManager().set_config_value(f"nodes.{service}.{value}", new_value)
881
924
 
882
925
  def clear_node(self) -> None:
@@ -884,6 +927,8 @@ class BaseNode(ABC):
884
927
  self.state = NodeResolutionState.UNRESOLVED
885
928
  # delete all output values potentially generated
886
929
  self.parameter_output_values.clear()
930
+ # Clear cancellation flag
931
+ self.clear_cancellation()
887
932
  # Clear the spotlight linked list
888
933
  # First, clear all next/prev pointers to break the linked list
889
934
  current = self.current_spotlight_parameter
@@ -946,6 +991,7 @@ class BaseNode(ABC):
946
991
  )
947
992
 
948
993
  def publish_update_to_parameter(self, parameter_name: str, value: Any) -> None:
994
+ from griptape_nodes.retained_mode.events.execution_events import ParameterValueUpdateEvent
949
995
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
950
996
 
951
997
  parameter = self.get_parameter_by_name(parameter_name)
@@ -1008,7 +1054,10 @@ class BaseNode(ABC):
1008
1054
 
1009
1055
  # Verify we have all elements
1010
1056
  if len(ordered_elements) != len(current_elements):
1011
- msg = "Element order must include all elements exactly once"
1057
+ ordered_names = {e.name for e in ordered_elements}
1058
+ current_names = {e.name for e in current_elements}
1059
+ diff = current_names - ordered_names
1060
+ msg = f"Element order must include all elements exactly once. Missing from new order: {diff}"
1012
1061
  raise ValueError(msg)
1013
1062
 
1014
1063
  # Remove all elements from root_ui_element
@@ -1182,6 +1231,47 @@ class BaseNode(ABC):
1182
1231
  # Use reorder_elements to apply the move
1183
1232
  self.reorder_elements(list(new_order))
1184
1233
 
1234
+ def get_element_index(self, element: str | BaseNodeElement, root: BaseNodeElement | None = None) -> int:
1235
+ """Get the current index of an element in the element list.
1236
+
1237
+ Args:
1238
+ element: The element to get the index for, specified by name or element object
1239
+ root: The root element to search within. If None, uses root_ui_element
1240
+
1241
+ Returns:
1242
+ The current index of the element (0-based)
1243
+
1244
+ Raises:
1245
+ ValueError: If element is not found
1246
+
1247
+ Example:
1248
+ # Get index by name in root container
1249
+ index = node.get_element_index("element1")
1250
+
1251
+ # Get index within a specific parameter group
1252
+ group = node.get_element_by_name_and_type("my_group", ParameterGroup)
1253
+ index = node.get_element_index("parameter1", root=group)
1254
+
1255
+ # Get index of a parameter to position another element relative to it
1256
+ reference_index = node.get_element_index("some_parameter")
1257
+ node.move_element_to_position("new_parameter", reference_index + 1)
1258
+ """
1259
+ # Use root_ui_element if no root specified
1260
+ if root is None:
1261
+ root = self.root_ui_element
1262
+
1263
+ # Get list of all element names in the root
1264
+ element_names = [child.name for child in root._children]
1265
+
1266
+ # Get element name
1267
+ if isinstance(element, str):
1268
+ element_name = element
1269
+ else:
1270
+ element_name = element.name
1271
+
1272
+ # Find the index of the element
1273
+ return element_names.index(element_name)
1274
+
1185
1275
 
1186
1276
  class TrackedParameterOutputValues(dict[str, Any]):
1187
1277
  """A dictionary that tracks modifications and emits AlterElementEvent when parameter output values change."""
@@ -1476,13 +1566,16 @@ class EndNode(BaseNode):
1476
1566
  case self.succeeded_control:
1477
1567
  was_successful = True
1478
1568
  status_prefix = "[SUCCEEDED]"
1569
+ logger.debug("End Node '%s': Matched succeeded_control path", self.name)
1479
1570
  case self.failed_control:
1480
1571
  was_successful = False
1481
1572
  status_prefix = "[FAILED]"
1573
+ logger.debug("End Node '%s': Matched failed_control path", self.name)
1482
1574
  case _:
1483
1575
  # No specific success/failure connection provided, assume success
1484
1576
  was_successful = True
1485
1577
  status_prefix = "[SUCCEEDED] No connection provided for success or failure, assuming successful"
1578
+ logger.debug("End Node '%s': No specific control connection, assuming success", self.name)
1486
1579
 
1487
1580
  # Get result details and format the final message
1488
1581
  result_details_value = self.get_parameter_value("result_details")
@@ -1500,10 +1593,10 @@ class EndNode(BaseNode):
1500
1593
  if param.type != ParameterTypeBuiltin.CONTROL_TYPE:
1501
1594
  value = self.get_parameter_value(param.name)
1502
1595
  self.parameter_output_values[param.name] = value
1503
- next_control_output = self.get_next_control_output()
1596
+ entry_parameter = self._entry_control_parameter
1504
1597
  # Update which control parameter to flag as the output value.
1505
- if next_control_output is not None:
1506
- self.parameter_output_values[next_control_output.name] = 1
1598
+ if entry_parameter is not None:
1599
+ self.parameter_output_values[entry_parameter.name] = CONTROL_INPUT_PARAMETER
1507
1600
 
1508
1601
 
1509
1602
  class StartLoopNode(BaseNode):
@@ -5,9 +5,8 @@ import logging
5
5
  from dataclasses import dataclass
6
6
  from typing import TYPE_CHECKING
7
7
 
8
- from griptape_nodes.exe_types.core_types import Parameter
9
- from griptape_nodes.exe_types.node_types import BaseNode, NodeResolutionState
10
- from griptape_nodes.exe_types.type_validator import TypeValidator
8
+ from griptape_nodes.exe_types.core_types import Parameter, ParameterTypeBuiltin
9
+ from griptape_nodes.exe_types.node_types import CONTROL_INPUT_PARAMETER, LOCAL_EXECUTION, BaseNode, NodeResolutionState
11
10
  from griptape_nodes.machines.fsm import FSM, State
12
11
  from griptape_nodes.machines.parallel_resolution import ParallelResolutionMachine
13
12
  from griptape_nodes.machines.sequential_resolution import SequentialResolutionMachine
@@ -45,6 +44,7 @@ class ControlFlowContext:
45
44
  selected_output: Parameter | None
46
45
  paused: bool = False
47
46
  flow_name: str
47
+ pickle_control_flow_result: bool
48
48
 
49
49
  def __init__(
50
50
  self,
@@ -52,6 +52,7 @@ class ControlFlowContext:
52
52
  max_nodes_in_parallel: int,
53
53
  *,
54
54
  execution_type: WorkflowExecutionMode = WorkflowExecutionMode.SEQUENTIAL,
55
+ pickle_control_flow_result: bool = False,
55
56
  ) -> None:
56
57
  self.flow_name = flow_name
57
58
  if execution_type == WorkflowExecutionMode.PARALLEL:
@@ -65,6 +66,7 @@ class ControlFlowContext:
65
66
  else:
66
67
  self.resolution_machine = SequentialResolutionMachine()
67
68
  self.current_nodes = []
69
+ self.pickle_control_flow_result = pickle_control_flow_result
68
70
 
69
71
  def get_next_nodes(self, output_parameter: Parameter | None = None) -> list[NextNodeInfo]:
70
72
  """Get all next nodes from the current nodes.
@@ -84,7 +86,10 @@ class ControlFlowContext:
84
86
  next_nodes.append(NextNodeInfo(node=node, entry_parameter=entry_parameter))
85
87
  else:
86
88
  # Get next control output for this node
87
- next_output = current_node.get_next_control_output()
89
+ if current_node.get_parameter_value(current_node.execution_environment.name) != LOCAL_EXECUTION:
90
+ next_output = self.get_next_control_output_for_non_local_execution(current_node)
91
+ else:
92
+ next_output = current_node.get_next_control_output()
88
93
  if next_output is not None:
89
94
  node_connection = (
90
95
  GriptapeNodes.FlowManager().get_connections().get_connected_node(current_node, next_output)
@@ -92,6 +97,8 @@ class ControlFlowContext:
92
97
  if node_connection is not None:
93
98
  node, entry_parameter = node_connection
94
99
  next_nodes.append(NextNodeInfo(node=node, entry_parameter=entry_parameter))
100
+ else:
101
+ logger.debug("Control Flow: Node '%s' has no control output", current_node.name)
95
102
 
96
103
  # If no connections found, check execution queue
97
104
  if not next_nodes:
@@ -101,6 +108,20 @@ class ControlFlowContext:
101
108
 
102
109
  return next_nodes
103
110
 
111
+ # Mirrored in @parallel_resolution.py. if you update one, update the other.
112
+ def get_next_control_output_for_non_local_execution(self, node: BaseNode) -> Parameter | None:
113
+ for param_name, value in node.parameter_output_values.items():
114
+ parameter = node.get_parameter_by_name(param_name)
115
+ if (
116
+ parameter is not None
117
+ and parameter.type == ParameterTypeBuiltin.CONTROL_TYPE
118
+ and value == CONTROL_INPUT_PARAMETER
119
+ ):
120
+ # This is the parameter
121
+ logger.debug("Control Flow: Found control output parameter '%s' for non-local execution", param_name)
122
+ return parameter
123
+ return None
124
+
104
125
  def reset(self, *, cancel: bool = False) -> None:
105
126
  if self.current_nodes is not None:
106
127
  for node in self.current_nodes:
@@ -152,7 +173,11 @@ class ResolveNodeState(State):
152
173
  await context.resolution_machine.resolve_node(current_node)
153
174
 
154
175
  if context.resolution_machine.is_complete():
176
+ # Get the last resolved node from the DAG and set it as current
155
177
  if isinstance(context.resolution_machine, ParallelResolutionMachine):
178
+ last_resolved_node = context.resolution_machine.get_last_resolved_node()
179
+ if last_resolved_node:
180
+ context.current_nodes = [last_resolved_node]
156
181
  return CompleteState
157
182
  return NextNodeState
158
183
  return None
@@ -219,12 +244,19 @@ class CompleteState(State):
219
244
  async def on_enter(context: ControlFlowContext) -> type[State] | None:
220
245
  # Broadcast completion events for any remaining current nodes
221
246
  for current_node in context.current_nodes:
247
+ # Use pickle-based serialization for complex parameter output values
248
+ from griptape_nodes.retained_mode.managers.node_manager import NodeManager
249
+
250
+ parameter_output_values, unique_uuid_to_values = NodeManager.serialize_parameter_output_values(
251
+ current_node, use_pickling=context.pickle_control_flow_result
252
+ )
222
253
  GriptapeNodes.EventManager().put_event(
223
254
  ExecutionGriptapeNodeEvent(
224
255
  wrapped_event=ExecutionEvent(
225
256
  payload=ControlFlowResolvedEvent(
226
257
  end_node_name=current_node.name,
227
- parameter_output_values=TypeValidator.safe_serialize(current_node.parameter_output_values),
258
+ parameter_output_values=parameter_output_values,
259
+ unique_parameter_uuid_to_values=unique_uuid_to_values if unique_uuid_to_values else None,
228
260
  )
229
261
  )
230
262
  )
@@ -239,12 +271,17 @@ class CompleteState(State):
239
271
 
240
272
  # MACHINE TIME!!!
241
273
  class ControlFlowMachine(FSM[ControlFlowContext]):
242
- def __init__(self, flow_name: str) -> None:
274
+ def __init__(self, flow_name: str, *, pickle_control_flow_result: bool = False) -> None:
243
275
  execution_type = GriptapeNodes.ConfigManager().get_config_value(
244
276
  "workflow_execution_mode", default=WorkflowExecutionMode.SEQUENTIAL
245
277
  )
246
278
  max_nodes_in_parallel = GriptapeNodes.ConfigManager().get_config_value("max_nodes_in_parallel", default=5)
247
- context = ControlFlowContext(flow_name, max_nodes_in_parallel, execution_type=execution_type)
279
+ context = ControlFlowContext(
280
+ flow_name,
281
+ max_nodes_in_parallel,
282
+ execution_type=execution_type,
283
+ pickle_control_flow_result=pickle_control_flow_result,
284
+ )
248
285
  super().__init__(context)
249
286
 
250
287
  async def start_flow(self, start_node: BaseNode, debug_mode: bool = False) -> None: # noqa: FBT001, FBT002
@@ -346,6 +383,10 @@ class ControlFlowMachine(FSM[ControlFlowContext]):
346
383
  flow_manager.global_flow_queue.queue.remove(item)
347
384
  return start_nodes
348
385
 
386
+ async def cancel_flow(self) -> None:
387
+ """Cancel all nodes in the flow by delegating to the resolution machine."""
388
+ await self.resolution_machine.cancel_all_nodes()
389
+
349
390
  def reset_machine(self, *, cancel: bool = False) -> None:
350
391
  self._context.reset(cancel=cancel)
351
392
  self._current_state = None