griptape-nodes 0.64.10__py3-none-any.whl → 0.65.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. griptape_nodes/app/app.py +25 -5
  2. griptape_nodes/cli/commands/init.py +65 -54
  3. griptape_nodes/cli/commands/libraries.py +92 -85
  4. griptape_nodes/cli/commands/self.py +121 -0
  5. griptape_nodes/common/node_executor.py +2142 -101
  6. griptape_nodes/exe_types/base_iterative_nodes.py +1004 -0
  7. griptape_nodes/exe_types/connections.py +114 -19
  8. griptape_nodes/exe_types/core_types.py +225 -7
  9. griptape_nodes/exe_types/flow.py +3 -3
  10. griptape_nodes/exe_types/node_types.py +681 -225
  11. griptape_nodes/exe_types/param_components/README.md +414 -0
  12. griptape_nodes/exe_types/param_components/api_key_provider_parameter.py +200 -0
  13. griptape_nodes/exe_types/param_components/huggingface/huggingface_model_parameter.py +2 -0
  14. griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_file_parameter.py +79 -5
  15. griptape_nodes/exe_types/param_types/parameter_button.py +443 -0
  16. griptape_nodes/machines/control_flow.py +77 -38
  17. griptape_nodes/machines/dag_builder.py +148 -70
  18. griptape_nodes/machines/parallel_resolution.py +61 -35
  19. griptape_nodes/machines/sequential_resolution.py +11 -113
  20. griptape_nodes/retained_mode/events/app_events.py +1 -0
  21. griptape_nodes/retained_mode/events/base_events.py +16 -13
  22. griptape_nodes/retained_mode/events/connection_events.py +3 -0
  23. griptape_nodes/retained_mode/events/execution_events.py +35 -0
  24. griptape_nodes/retained_mode/events/flow_events.py +15 -2
  25. griptape_nodes/retained_mode/events/library_events.py +347 -0
  26. griptape_nodes/retained_mode/events/node_events.py +48 -0
  27. griptape_nodes/retained_mode/events/os_events.py +86 -3
  28. griptape_nodes/retained_mode/events/project_events.py +15 -1
  29. griptape_nodes/retained_mode/events/workflow_events.py +48 -1
  30. griptape_nodes/retained_mode/griptape_nodes.py +6 -2
  31. griptape_nodes/retained_mode/managers/config_manager.py +10 -8
  32. griptape_nodes/retained_mode/managers/event_manager.py +168 -0
  33. griptape_nodes/retained_mode/managers/fitness_problems/libraries/__init__.py +2 -0
  34. griptape_nodes/retained_mode/managers/fitness_problems/libraries/old_xdg_location_warning_problem.py +43 -0
  35. griptape_nodes/retained_mode/managers/flow_manager.py +664 -123
  36. griptape_nodes/retained_mode/managers/library_manager.py +1143 -139
  37. griptape_nodes/retained_mode/managers/model_manager.py +2 -3
  38. griptape_nodes/retained_mode/managers/node_manager.py +148 -25
  39. griptape_nodes/retained_mode/managers/object_manager.py +3 -1
  40. griptape_nodes/retained_mode/managers/operation_manager.py +3 -1
  41. griptape_nodes/retained_mode/managers/os_manager.py +1158 -122
  42. griptape_nodes/retained_mode/managers/secrets_manager.py +2 -3
  43. griptape_nodes/retained_mode/managers/settings.py +21 -1
  44. griptape_nodes/retained_mode/managers/sync_manager.py +2 -3
  45. griptape_nodes/retained_mode/managers/workflow_manager.py +358 -104
  46. griptape_nodes/retained_mode/retained_mode.py +3 -3
  47. griptape_nodes/traits/button.py +44 -2
  48. griptape_nodes/traits/file_system_picker.py +2 -2
  49. griptape_nodes/utils/file_utils.py +101 -0
  50. griptape_nodes/utils/git_utils.py +1226 -0
  51. griptape_nodes/utils/library_utils.py +122 -0
  52. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/METADATA +2 -1
  53. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/RECORD +55 -47
  54. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/WHEEL +1 -1
  55. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/entry_points.txt +0 -0
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import logging
4
4
  import threading
5
5
  import warnings
6
- from abc import ABC, abstractmethod
6
+ from abc import ABC
7
7
  from collections.abc import Callable, Generator, Iterable
8
8
  from dataclasses import dataclass, field
9
9
  from enum import StrEnum, auto
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
11
11
 
12
12
  from griptape_nodes.exe_types.core_types import (
13
13
  BaseNodeElement,
14
+ ControlParameter,
14
15
  ControlParameterInput,
15
16
  ControlParameterOutput,
16
17
  NodeMessageResult,
@@ -22,6 +23,7 @@ from griptape_nodes.exe_types.core_types import (
22
23
  ParameterMessage,
23
24
  ParameterMode,
24
25
  ParameterTypeBuiltin,
26
+ Trait,
25
27
  )
26
28
  from griptape_nodes.exe_types.param_components.execution_status_component import ExecutionStatusComponent
27
29
  from griptape_nodes.exe_types.type_validator import TypeValidator
@@ -31,8 +33,14 @@ from griptape_nodes.retained_mode.events.base_events import (
31
33
  ProgressEvent,
32
34
  RequestPayload,
33
35
  )
36
+ from griptape_nodes.retained_mode.events.connection_events import (
37
+ CreateConnectionRequest,
38
+ DeleteConnectionRequest,
39
+ DeleteConnectionResultSuccess,
40
+ )
34
41
  from griptape_nodes.retained_mode.events.parameter_events import (
35
42
  AddParameterToNodeRequest,
43
+ AddParameterToNodeResultSuccess,
36
44
  RemoveElementEvent,
37
45
  RemoveParameterFromNodeRequest,
38
46
  )
@@ -40,6 +48,7 @@ from griptape_nodes.traits.options import Options
40
48
  from griptape_nodes.utils import async_utils
41
49
 
42
50
  if TYPE_CHECKING:
51
+ from griptape_nodes.exe_types.connections import Connections
43
52
  from griptape_nodes.exe_types.core_types import NodeMessagePayload
44
53
  from griptape_nodes.node_library.library_registry import LibraryNameAndVersion
45
54
 
@@ -47,6 +56,41 @@ logger = logging.getLogger("griptape_nodes")
47
56
 
48
57
  T = TypeVar("T")
49
58
 
59
+ NODE_GROUP_FLOW = "NodeGroupFlow"
60
+
61
+
62
+ class TransformedParameterValue(NamedTuple):
63
+ """Return type for BaseNode.before_value_set() to transform both value and type.
64
+
65
+ When before_value_set() needs to transform a parameter value to a different type
66
+ (e.g., converting a string path to an artifact object), it can return this NamedTuple
67
+ to inform the node manager of both the new value AND its type. This ensures proper
68
+ type validation during parameter setting.
69
+
70
+ If before_value_set() only transforms the value without changing its type, it can
71
+ return the value directly without using this NamedTuple.
72
+
73
+ Example:
74
+ def before_value_set(self, parameter: Parameter, value: Any) -> Any:
75
+ if parameter == self.artifact_param and isinstance(value, str):
76
+ # Transform string to artifact
77
+ artifact = self._create_artifact(value)
78
+ # Return both transformed value and its type
79
+ return TransformedParameterValue(
80
+ value=artifact,
81
+ parameter_type=self.artifact_param.output_type
82
+ )
83
+ return value
84
+
85
+ Attributes:
86
+ value: The transformed parameter value
87
+ parameter_type: The type string of the transformed value (e.g., "ImageArtifact")
88
+ """
89
+
90
+ value: Any
91
+ parameter_type: str
92
+
93
+
50
94
  AsyncResult = Generator[Callable[[], T], T]
51
95
 
52
96
  LOCAL_EXECUTION = "Local Execution"
@@ -344,7 +388,7 @@ class BaseNode(ABC):
344
388
  self,
345
389
  parameter: Parameter, # noqa: ARG002
346
390
  value: Any,
347
- ) -> Any:
391
+ ) -> Any | TransformedParameterValue:
348
392
  """Callback when a Parameter's value is ABOUT to be set.
349
393
 
350
394
  Custom nodes may elect to override the default behavior by implementing this function in their node code.
@@ -362,7 +406,10 @@ class BaseNode(ABC):
362
406
 
363
407
  Returns:
364
408
  The final value to set for the Parameter. This gives the Node logic one last opportunity to mutate the value
365
- before it is assigned.
409
+ before it is assigned. Can return either:
410
+ * The transformed value directly (if type doesn't change)
411
+ * TransformedParameterValue(value=..., parameter_type=...) to specify both value and type
412
+ when transforming to a different type (e.g., string to artifact)
366
413
  """
367
414
  # Default behavior is to do nothing to the supplied value, and indicate no other modified Parameters.
368
415
  return value
@@ -1628,22 +1675,10 @@ class EndNode(BaseNode):
1628
1675
  self.parameter_output_values[entry_parameter.name] = CONTROL_INPUT_PARAMETER
1629
1676
 
1630
1677
 
1631
- class StartLoopNode(BaseNode):
1632
- end_node: EndLoopNode | None = None
1633
- """Creating class for Start Loop Node in order to implement loop functionality in execution."""
1634
-
1635
- @abstractmethod
1636
- def is_loop_finished(self) -> bool:
1637
- """Return True if the loop has finished executing.
1638
-
1639
- This method must be implemented by subclasses to define when
1640
- the loop should terminate.
1641
- """
1642
-
1643
-
1644
- class EndLoopNode(BaseNode):
1645
- start_node: StartLoopNode | None = None
1646
- """Creating class for Start Loop Node in order to implement loop functionality in execution."""
1678
+ # StartLoopNode and EndLoopNode have been moved to base_iterative_nodes.py
1679
+ # They are now BaseIterativeStartNode and BaseIterativeEndNode
1680
+ # Import them here if needed for backwards compatibility in this file
1681
+ # (they are imported elsewhere directly from base_iterative_nodes)
1647
1682
 
1648
1683
 
1649
1684
  class ErrorProxyNode(BaseNode):
@@ -1894,8 +1929,7 @@ class NodeGroupNode(BaseNode):
1894
1929
  """
1895
1930
 
1896
1931
  nodes: dict[str, BaseNode]
1897
- stored_connections: NodeGroupStoredConnections
1898
- _proxy_param_to_node_param: dict[str, tuple[BaseNode, str]]
1932
+ _proxy_param_to_connections: dict[str, int]
1899
1933
 
1900
1934
  def __init__(
1901
1935
  self,
@@ -1914,172 +1948,381 @@ class NodeGroupNode(BaseNode):
1914
1948
  self.add_parameter(self.execution_environment)
1915
1949
  self.nodes = {}
1916
1950
  # Track mapping from proxy parameter name to (original_node, original_param_name)
1917
- self._proxy_param_to_node_param = {}
1918
- self.stored_connections = NodeGroupStoredConnections()
1919
-
1920
- def get_all_nodes(self) -> dict[str, BaseNode]:
1921
- all_nodes = {}
1922
- for node_name, node in self.nodes.items():
1923
- all_nodes[node_name] = node
1924
- if isinstance(node, NodeGroupNode):
1925
- all_nodes.update(node.nodes)
1926
- return all_nodes
1951
+ self._proxy_param_to_connections = {}
1952
+ if "execution_environment" not in self.metadata:
1953
+ self.metadata["execution_environment"] = {}
1954
+ self.metadata["execution_environment"]["Griptape Nodes Library"] = {
1955
+ "start_flow_node": "StartFlow",
1956
+ "parameter_names": {},
1957
+ }
1927
1958
 
1928
- def _find_intermediate_nodes( # noqa: C901
1929
- self, start_node: BaseNode, end_node: BaseNode
1930
- ) -> set[BaseNode]:
1931
- """Find all nodes on paths between start_node and end_node (excluding endpoints).
1959
+ # Don't create subflow in __init__ - it will be created on-demand when nodes are added
1960
+ # or restored during deserialization
1932
1961
 
1933
- Uses BFS to explore all paths from start_node to end_node and collects
1934
- all nodes encountered (except start and end nodes themselves).
1962
+ # Add parameters from registered StartFlow nodes for each publishing library
1963
+ self._add_start_flow_parameters()
1935
1964
 
1936
- Args:
1937
- start_node: Starting node for path search
1938
- end_node: Target node for path search
1965
+ def _create_subflow(self) -> None:
1966
+ """Create a dedicated subflow for this NodeGroup's nodes.
1939
1967
 
1940
- Returns:
1941
- Set of nodes found on paths between start and end (excluding endpoints)
1968
+ Note: This is called during __init__, so the node may not yet be added to a flow.
1969
+ The subflow will be created without a parent initially, and can be reparented later.
1942
1970
  """
1943
- # Build a lookup dictionary for faster connection queries
1944
- # Map from (source_node_name, source_param_name) -> list of connections
1945
- outgoing_lookup: dict[tuple[str, str], list[Connection]] = {}
1971
+ from griptape_nodes.retained_mode.events.flow_events import (
1972
+ CreateFlowRequest,
1973
+ CreateFlowResultSuccess,
1974
+ )
1975
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
1946
1976
 
1947
- for conn in self.stored_connections.internal_connections:
1948
- key = (conn.source_node.name, conn.source_parameter.name)
1949
- if key not in outgoing_lookup:
1950
- outgoing_lookup[key] = []
1951
- outgoing_lookup[key].append(conn)
1977
+ subflow_name = f"{self.name}_subflow"
1978
+ self.metadata["subflow_name"] = subflow_name
1952
1979
 
1953
- visited = set()
1954
- intermediate = set()
1955
- queue = [(start_node, [start_node])]
1980
+ # Get current flow to set as parent so subflow will be serialized with parent
1981
+ current_flow = GriptapeNodes.ContextManager().get_current_flow()
1982
+ parent_flow_name = current_flow.name if current_flow else None
1956
1983
 
1957
- while queue:
1958
- current_node, path = queue.pop(0)
1984
+ # Create metadata with flow_type
1985
+ subflow_metadata = {"flow_type": NODE_GROUP_FLOW}
1959
1986
 
1960
- if current_node.name in visited:
1961
- continue
1962
- visited.add(current_node.name)
1963
-
1964
- # Process outgoing connections from current node
1965
- current_outgoing = []
1966
- for param_name in [p.name for p in current_node.parameters]:
1967
- key = (current_node.name, param_name)
1968
- if key in outgoing_lookup:
1969
- current_outgoing.extend(outgoing_lookup[key])
1970
-
1971
- for conn in current_outgoing:
1972
- next_node = conn.target_node
1973
-
1974
- # If we reached the end node, record intermediate nodes
1975
- if next_node == end_node:
1976
- for node in path[1:]:
1977
- intermediate.add(node)
1978
- continue
1987
+ request = CreateFlowRequest(
1988
+ flow_name=subflow_name,
1989
+ parent_flow_name=parent_flow_name,
1990
+ set_as_new_context=False,
1991
+ metadata=subflow_metadata,
1992
+ )
1993
+ result = GriptapeNodes.handle_request(request)
1979
1994
 
1980
- # Continue exploring if not already visited
1981
- if next_node.name not in visited:
1982
- queue.append((next_node, [*path, next_node]))
1995
+ if not isinstance(result, CreateFlowResultSuccess):
1996
+ logger.warning("%s failed to create subflow '%s': %s", self.name, subflow_name, result.result_details)
1983
1997
 
1984
- return intermediate
1998
+ def _add_start_flow_parameters(self) -> None:
1999
+ """Add parameters from all registered StartFlow nodes to this NodeGroupNode.
1985
2000
 
1986
- def validate_no_intermediate_nodes(self) -> None:
1987
- """Validate that no ungrouped nodes exist between grouped nodes.
2001
+ For each library that has registered a PublishWorkflowRequest handler with
2002
+ a StartFlow node, this method:
2003
+ 1. Creates a temporary instance of that StartFlow node
2004
+ 2. Extracts all its parameters
2005
+ 3. Adds them to this NodeGroupNode with a prefix based on the class name
2006
+ 4. Stores metadata mapping execution environments to their parameters
2007
+ """
2008
+ from griptape_nodes.retained_mode.events.workflow_events import PublishWorkflowRequest
2009
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
2010
+
2011
+ # Initialize metadata structure for execution environment mappings
2012
+ if self.metadata is None:
2013
+ self.metadata = {}
2014
+ if "execution_environment" not in self.metadata:
2015
+ self.metadata["execution_environment"] = {}
1988
2016
 
1989
- This method checks the dependency graph to ensure that all nodes that lie
1990
- on paths between grouped nodes are also part of the group. If ungrouped
1991
- nodes are found between grouped nodes, this indicates a logical error in
1992
- the group definition.
2017
+ # Get all libraries that have registered PublishWorkflowRequest handlers
2018
+ library_manager = GriptapeNodes.LibraryManager()
2019
+ event_handlers = library_manager.get_registered_event_handlers(PublishWorkflowRequest)
1993
2020
 
1994
- Raises:
1995
- ValueError: If ungrouped nodes are found between grouped nodes
2021
+ # Process each registered library
2022
+ for library_name, handler in event_handlers.items():
2023
+ self._process_library_start_flow_parameters(library_name, handler)
2024
+
2025
+ def _process_library_start_flow_parameters(self, library_name: str, handler: Any) -> None:
2026
+ """Process and add StartFlow parameters from a single library.
2027
+
2028
+ Args:
2029
+ library_name: Name of the library
2030
+ handler: The registered event handler containing event data
1996
2031
  """
1997
- # Check each pair of nodes in the group
1998
- for node_a in self.nodes.values():
1999
- for node_b in self.nodes.values():
2000
- if node_a == node_b:
2001
- continue
2032
+ import logging
2002
2033
 
2003
- # Check if there's a path from node_a to node_b
2004
- intermediate_nodes = self._find_intermediate_nodes(node_a, node_b)
2034
+ from griptape_nodes.node_library.library_registry import LibraryRegistry
2035
+ from griptape_nodes.retained_mode.events.workflow_events import PublishWorkflowRegisteredEventData
2005
2036
 
2006
- # Check if any intermediate nodes are not in the group
2007
- ungrouped_intermediates = [n for n in intermediate_nodes if n.name not in self.nodes]
2037
+ logger = logging.getLogger(__name__)
2008
2038
 
2009
- if ungrouped_intermediates:
2010
- ungrouped_names = [n.name for n in ungrouped_intermediates]
2011
- msg = (
2012
- f"Invalid node group '{self.name}': Found ungrouped nodes between grouped nodes. "
2013
- f"Ungrouped nodes {ungrouped_names} exist on the path from '{node_a.name}' to '{node_b.name}'. "
2014
- f"All nodes on paths between grouped nodes must be part of the same group."
2015
- )
2016
- raise ValueError(msg)
2039
+ registered_event_data = handler.event_data
2040
+
2041
+ if registered_event_data is None:
2042
+ return
2043
+ if not isinstance(registered_event_data, PublishWorkflowRegisteredEventData):
2044
+ return
2045
+
2046
+ # Get the StartFlow node information
2047
+ start_flow_node_type = registered_event_data.start_flow_node_type
2048
+ start_flow_library_name = registered_event_data.start_flow_node_library_name
2049
+
2050
+ try:
2051
+ # Get the library that contains the StartFlow node
2052
+ library = LibraryRegistry.get_library(name=start_flow_library_name)
2053
+ except KeyError:
2054
+ logger.debug(
2055
+ "Library '%s' not found when adding StartFlow parameters for '%s'",
2056
+ start_flow_library_name,
2057
+ library_name,
2058
+ )
2059
+ return
2060
+
2061
+ try:
2062
+ # Create a temporary instance of the StartFlow node to inspect its parameters
2063
+ temp_start_flow_node = library.create_node(
2064
+ node_type=start_flow_node_type,
2065
+ name=f"temp_{start_flow_node_type}",
2066
+ )
2067
+ except Exception as e:
2068
+ logger.debug(
2069
+ "Failed to create temporary StartFlow node '%s' from library '%s': %s",
2070
+ start_flow_node_type,
2071
+ start_flow_library_name,
2072
+ e,
2073
+ )
2074
+ return
2075
+
2076
+ # Get the class name for prefixing (convert to lowercase for parameter naming)
2077
+ class_name_prefix = start_flow_node_type.lower()
2078
+
2079
+ # Store metadata for this execution environment
2080
+ parameter_names = []
2081
+
2082
+ # Add each parameter from the StartFlow node to this NodeGroupNode
2083
+ for param in temp_start_flow_node.parameters:
2084
+ if isinstance(param, ControlParameter):
2085
+ continue
2017
2086
 
2018
- def track_internal_connection(self, conn: Connection) -> None:
2019
- """Track a connection between nodes within the group.
2087
+ # Create prefixed parameter name
2088
+ prefixed_param_name = f"{class_name_prefix}_{param.name}"
2089
+ parameter_names.append(prefixed_param_name)
2090
+
2091
+ # Clone and add the parameter
2092
+ self._clone_and_add_parameter(param, prefixed_param_name)
2093
+
2094
+ # Store the mapping in metadata
2095
+ self.metadata["execution_environment"][library_name] = {
2096
+ "start_flow_node": start_flow_node_type,
2097
+ "parameter_names": parameter_names,
2098
+ }
2099
+
2100
+ def _clone_and_add_parameter(self, param: Parameter, new_name: str) -> None:
2101
+ """Clone a parameter with a new name and add it to this node.
2020
2102
 
2021
2103
  Args:
2022
- conn: The internal connection to track
2104
+ param: The parameter to clone
2105
+ new_name: The new name for the cloned parameter
2023
2106
  """
2024
- if conn not in self.stored_connections.internal_connections:
2025
- self.stored_connections.internal_connections.append(conn)
2107
+ # Extract traits from parameter children (traits are stored as children of type Trait)
2108
+ traits_set: set[type[Trait] | Trait] | None = {child for child in param.children if isinstance(child, Trait)}
2109
+ if not traits_set:
2110
+ traits_set = None
2111
+
2112
+ # Clone the parameter with the new name
2113
+ cloned_param = Parameter(
2114
+ name=new_name,
2115
+ tooltip=param.tooltip,
2116
+ type=param.type,
2117
+ allowed_modes=param.allowed_modes,
2118
+ default_value=param.default_value,
2119
+ traits=traits_set,
2120
+ parent_container_name=param.parent_container_name,
2121
+ parent_element_name=param.parent_element_name,
2122
+ )
2026
2123
 
2027
- def track_external_connection(
2028
- self,
2029
- conn: Connection,
2030
- conn_id: int,
2031
- is_incoming: bool, # noqa: FBT001
2032
- grouped_node: BaseNode,
2033
- ) -> None:
2034
- """Track a connection to/from a node in the group.
2124
+ # Add the parameter to this node
2125
+ self.add_parameter(cloned_param)
2126
+
2127
+ def _create_proxy_parameter_for_connection(self, original_param: Parameter, *, is_incoming: bool) -> Parameter:
2128
+ """Create a proxy parameter on this NodeGroupNode for an external connection.
2035
2129
 
2036
2130
  Args:
2037
- conn: The external connection to track
2038
- conn_id: ID of the connection
2039
- is_incoming: True if connection is coming INTO the group
2040
- grouped_node: The node in the group involved in the connection
2131
+ original_param: The parameter from the grouped node
2132
+ grouped_node: The node within the group that has the original parameter
2133
+ conn_id: The connection ID for uniqueness
2134
+ is_incoming: True if this is an incoming connection to the group
2135
+
2136
+ Returns:
2137
+ The newly created proxy parameter
2041
2138
  """
2139
+ # Clone the parameter with the new name
2140
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
2141
+
2142
+ input_types = None
2143
+ output_type = None
2144
+ if is_incoming:
2145
+ input_types = original_param.input_types
2146
+ else:
2147
+ output_type = original_param.output_type
2148
+
2149
+ request = AddParameterToNodeRequest(
2150
+ node_name=self.name,
2151
+ parameter_name=original_param.name,
2152
+ input_types=input_types,
2153
+ output_type=output_type,
2154
+ tooltip="",
2155
+ mode_allowed_input=True,
2156
+ mode_allowed_output=True,
2157
+ )
2158
+ # Add with a request, because this will handle naming for us.
2159
+ result = GriptapeNodes.handle_request(request)
2160
+ if not isinstance(result, AddParameterToNodeResultSuccess):
2161
+ msg = "Failed to add parameter to node."
2162
+ raise TypeError(msg)
2163
+ # Retrieve and return the newly created parameter
2164
+ proxy_param = self.get_parameter_by_name(result.parameter_name)
2165
+ if proxy_param is None:
2166
+ msg = f"{self.name} failed to create proxy parameter '{result.parameter_name}'"
2167
+ raise RuntimeError(msg)
2042
2168
  if is_incoming:
2043
- if conn not in self.stored_connections.external_connections.incoming_connections:
2044
- self.stored_connections.external_connections.incoming_connections.append(conn)
2045
- self.stored_connections.original_targets.incoming_sources[conn_id] = grouped_node
2169
+ if "left_parameters" in self.metadata:
2170
+ self.metadata["left_parameters"].append(proxy_param.name)
2171
+ else:
2172
+ self.metadata["left_parameters"] = [proxy_param.name]
2173
+ elif "right_parameters" in self.metadata:
2174
+ self.metadata["right_parameters"].append(proxy_param.name)
2046
2175
  else:
2047
- if conn not in self.stored_connections.external_connections.outgoing_connections:
2048
- self.stored_connections.external_connections.outgoing_connections.append(conn)
2049
- self.stored_connections.original_targets.outgoing_targets[conn_id] = grouped_node
2176
+ self.metadata["right_parameters"] = [proxy_param.name]
2050
2177
 
2051
- def untrack_internal_connection(self, conn: Connection) -> None:
2052
- """Remove tracking of an internal connection.
2178
+ return proxy_param
2053
2179
 
2054
- Args:
2055
- conn: The internal connection to untrack
2056
- """
2057
- if conn in self.stored_connections.internal_connections:
2058
- self.stored_connections.internal_connections.remove(conn)
2180
+ def get_all_nodes(self) -> dict[str, BaseNode]:
2181
+ all_nodes = {}
2182
+ for node_name, node in self.nodes.items():
2183
+ all_nodes[node_name] = node
2184
+ if isinstance(node, NodeGroupNode):
2185
+ all_nodes.update(node.nodes)
2186
+ return all_nodes
2059
2187
 
2060
- def untrack_external_connection(
2061
- self,
2062
- conn: Connection,
2063
- conn_id: int,
2064
- is_incoming: bool, # noqa: FBT001
2065
- ) -> None:
2066
- """Remove tracking of an external connection.
2188
+ def map_external_connection(self, conn: Connection, *, is_incoming: bool) -> bool:
2189
+ """Track a connection to/from a node in the group and rewire it through a proxy parameter.
2067
2190
 
2068
2191
  Args:
2069
- conn: The external connection to untrack
2192
+ conn: The external connection to track
2070
2193
  conn_id: ID of the connection
2071
- is_incoming: True if connection was coming INTO the group
2194
+ is_incoming: True if connection is coming INTO the group
2072
2195
  """
2073
2196
  if is_incoming:
2074
- if conn in self.stored_connections.external_connections.incoming_connections:
2075
- self.stored_connections.external_connections.incoming_connections.remove(conn)
2076
- if conn_id in self.stored_connections.original_targets.incoming_sources:
2077
- del self.stored_connections.original_targets.incoming_sources[conn_id]
2197
+ grouped_parameter = conn.target_parameter
2198
+ # Store the existing connection so it can be recreated if needed.
2199
+ else:
2200
+ grouped_parameter = conn.source_parameter
2201
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
2202
+
2203
+ request = DeleteConnectionRequest(
2204
+ conn.source_parameter.name,
2205
+ conn.target_parameter.name,
2206
+ conn.source_node.name,
2207
+ conn.target_node.name,
2208
+ )
2209
+ result = GriptapeNodes.handle_request(request)
2210
+ if not isinstance(result, DeleteConnectionResultSuccess):
2211
+ return False
2212
+ proxy_parameter = self._create_proxy_parameter_for_connection(grouped_parameter, is_incoming=is_incoming)
2213
+ # Create connections for proxy parameter
2214
+ self.create_connections_for_proxy(proxy_parameter, conn, is_incoming=is_incoming)
2215
+ return True
2216
+
2217
+ def create_connections_for_proxy(
2218
+ self, proxy_parameter: Parameter, old_connection: Connection, *, is_incoming: bool
2219
+ ) -> None:
2220
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
2221
+
2222
+ create_first_connection = CreateConnectionRequest(
2223
+ source_parameter_name=old_connection.source_parameter.name,
2224
+ target_parameter_name=proxy_parameter.name,
2225
+ source_node_name=old_connection.source_node.name,
2226
+ target_node_name=self.name,
2227
+ is_node_group_internal=not is_incoming,
2228
+ )
2229
+ create_second_connection = CreateConnectionRequest(
2230
+ source_parameter_name=proxy_parameter.name,
2231
+ target_parameter_name=old_connection.target_parameter.name,
2232
+ source_node_name=self.name,
2233
+ target_node_name=old_connection.target_node.name,
2234
+ is_node_group_internal=is_incoming,
2235
+ )
2236
+ # Store the mapping from proxy parameter to original node/parameter
2237
+ # only increment by 1, even though we're making two connections.
2238
+ if proxy_parameter.name not in self._proxy_param_to_connections:
2239
+ self._proxy_param_to_connections[proxy_parameter.name] = 2
2078
2240
  else:
2079
- if conn in self.stored_connections.external_connections.outgoing_connections:
2080
- self.stored_connections.external_connections.outgoing_connections.remove(conn)
2081
- if conn_id in self.stored_connections.original_targets.outgoing_targets:
2082
- del self.stored_connections.original_targets.outgoing_targets[conn_id]
2241
+ self._proxy_param_to_connections[proxy_parameter.name] += 2
2242
+ GriptapeNodes.handle_request(create_first_connection)
2243
+ GriptapeNodes.handle_request(create_second_connection)
2244
+
2245
+ def unmap_node_connections(self, node: BaseNode, connections: Connections) -> None: # noqa: C901
2246
+ """Remove tracking of an external connection, restore original connection, and clean up proxy parameter.
2247
+
2248
+ Args:
2249
+ node: The node to unmap
2250
+ connections: The connections object
2251
+ """
2252
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
2253
+
2254
+ # For the node being removed - We need to figure out all of it's connections TO the node group. These connections need to be remapped.
2255
+ # If we delete connections from a proxy parameter, and it has no more connections, then the proxy parameter should be deleted unless it's user defined.
2256
+ # It will 1. not be in the proxy map. and 2. it will have a value of > 0
2257
+ # Get all outgoing connections
2258
+ outgoing_connections = connections.get_outgoing_connections_to_node(node, to_node=self)
2259
+ # Delete outgoing connections
2260
+ for parameter_name, outgoing_connection_list in outgoing_connections.items():
2261
+ for outgoing_connection in outgoing_connection_list:
2262
+ proxy_parameter = outgoing_connection.target_parameter
2263
+ # get old connections first, since this will delete the proxy
2264
+ remap_connections = connections.get_outgoing_connections_from_parameter(self, proxy_parameter)
2265
+ # Delete the internal connection
2266
+ delete_result = GriptapeNodes.FlowManager().on_delete_connection_request(
2267
+ DeleteConnectionRequest(
2268
+ source_parameter_name=parameter_name,
2269
+ target_parameter_name=proxy_parameter.name,
2270
+ source_node_name=node.name,
2271
+ target_node_name=self.name,
2272
+ )
2273
+ )
2274
+ if delete_result.failed():
2275
+ msg = f"{self.name}: Failed to delete internal outgoing connection from {node.name}.{parameter_name} to proxy {proxy_parameter.name}: {delete_result.result_details}"
2276
+ raise RuntimeError(msg)
2277
+
2278
+ # Now create the new connection! We need to get the connections from the proxy parameter
2279
+ for connection in remap_connections:
2280
+ create_result = GriptapeNodes.FlowManager().on_create_connection_request(
2281
+ CreateConnectionRequest(
2282
+ source_parameter_name=parameter_name,
2283
+ target_parameter_name=connection.target_parameter.name,
2284
+ source_node_name=node.name,
2285
+ target_node_name=connection.target_node.name,
2286
+ )
2287
+ )
2288
+ if create_result.failed():
2289
+ msg = f"{self.name}: Failed to create direct outgoing connection from {node.name}.{parameter_name} to {connection.target_node.name}.{connection.target_parameter.name}: {create_result.result_details}"
2290
+ raise RuntimeError(msg)
2291
+
2292
+ # Get all incoming connections
2293
+ incoming_connections = connections.get_incoming_connections_from_node(node, from_node=self)
2294
+ # Delete incoming connections
2295
+ for parameter_name, incoming_connection_list in incoming_connections.items():
2296
+ for incoming_connection in incoming_connection_list:
2297
+ proxy_parameter = incoming_connection.source_parameter
2298
+ # Get the incoming connections to the proxy parameter
2299
+ remap_connections = connections.get_incoming_connections_to_parameter(self, proxy_parameter)
2300
+ # Delete the internal connection
2301
+ delete_result = GriptapeNodes.FlowManager().on_delete_connection_request(
2302
+ DeleteConnectionRequest(
2303
+ source_parameter_name=proxy_parameter.name,
2304
+ target_parameter_name=parameter_name,
2305
+ source_node_name=self.name,
2306
+ target_node_name=node.name,
2307
+ )
2308
+ )
2309
+ if delete_result.failed():
2310
+ msg = f"{self.name}: Failed to delete internal incoming connection from proxy {proxy_parameter.name} to {node.name}.{parameter_name}: {delete_result.result_details}"
2311
+ raise RuntimeError(msg)
2312
+
2313
+ # Now create the new connection! We need to get the connections to the proxy parameter
2314
+ for connection in remap_connections:
2315
+ create_result = GriptapeNodes.FlowManager().on_create_connection_request(
2316
+ CreateConnectionRequest(
2317
+ source_parameter_name=connection.source_parameter.name,
2318
+ target_parameter_name=parameter_name,
2319
+ source_node_name=connection.source_node.name,
2320
+ target_node_name=node.name,
2321
+ )
2322
+ )
2323
+ if create_result.failed():
2324
+ msg = f"{self.name}: Failed to create direct incoming connection from {connection.source_node.name}.{connection.source_parameter.name} to {node.name}.{parameter_name}: {create_result.result_details}"
2325
+ raise RuntimeError(msg)
2083
2326
 
2084
2327
  def _remove_nodes_from_existing_parents(self, nodes: list[BaseNode]) -> None:
2085
2328
  """Remove nodes from their existing parent groups."""
@@ -2098,35 +2341,169 @@ class NodeGroupNode(BaseNode):
2098
2341
  node.parent_group = self
2099
2342
  self.nodes[node.name] = node
2100
2343
 
2101
- def _track_incoming_connections(self, node: BaseNode, connections: Any, node_names_in_group: set[str]) -> None:
2102
- """Track incoming external connections for a node."""
2103
- if node.name not in connections.incoming_index:
2344
+ def _cleanup_proxy_parameter(self, proxy_parameter: Parameter, metadata_key: str) -> None:
2345
+ """Clean up proxy parameter if it has no more connections.
2346
+
2347
+ Args:
2348
+ proxy_parameter: The proxy parameter to potentially clean up
2349
+ metadata_key: The metadata key ('left_parameters' or 'right_parameters')
2350
+ """
2351
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
2352
+
2353
+ if proxy_parameter.name not in self._proxy_param_to_connections:
2104
2354
  return
2105
2355
 
2106
- for connection_ids in connections.incoming_index[node.name].values():
2107
- for conn_id in connection_ids:
2108
- if conn_id not in connections.connections:
2109
- continue
2110
- conn = connections.connections[conn_id]
2356
+ self._proxy_param_to_connections[proxy_parameter.name] -= 1
2357
+ if self._proxy_param_to_connections[proxy_parameter.name] == 0:
2358
+ GriptapeNodes.NodeManager().on_remove_parameter_from_node_request(
2359
+ request=RemoveParameterFromNodeRequest(node_name=self.name, parameter_name=proxy_parameter.name)
2360
+ )
2361
+ del self._proxy_param_to_connections[proxy_parameter.name]
2362
+ if metadata_key in self.metadata and proxy_parameter.name in self.metadata[metadata_key]:
2363
+ self.metadata[metadata_key].remove(proxy_parameter.name)
2111
2364
 
2112
- if conn.source_node.name not in node_names_in_group:
2113
- self.track_external_connection(conn, conn_id, is_incoming=True, grouped_node=node)
2114
- elif conn not in self.stored_connections.internal_connections:
2115
- self.track_internal_connection(conn)
2365
+ def _remap_outgoing_connections(self, node: BaseNode, connections: Connections) -> None:
2366
+ """Remap outgoing connections that go through proxy parameters.
2116
2367
 
2117
- def _track_outgoing_connections(self, node: BaseNode, connections: Any, node_names_in_group: set[str]) -> None:
2118
- """Track outgoing external connections for a node."""
2119
- if node.name not in connections.outgoing_index:
2120
- return
2368
+ Args:
2369
+ node: The node being added to the group
2370
+ connections: Connections object from FlowManager
2371
+ """
2372
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
2121
2373
 
2122
- for connection_ids in connections.outgoing_index[node.name].values():
2123
- for conn_id in connection_ids:
2124
- if conn_id not in connections.connections:
2125
- continue
2126
- conn = connections.connections[conn_id]
2374
+ outgoing_connections = connections.get_outgoing_connections_to_node(node, to_node=self)
2375
+ for parameter_name, outgoing_connection_list in outgoing_connections.items():
2376
+ for outgoing_connection in outgoing_connection_list:
2377
+ proxy_parameter = outgoing_connection.target_parameter
2378
+ remap_connections = connections.get_outgoing_connections_from_parameter(self, proxy_parameter)
2379
+ delete_result = GriptapeNodes.FlowManager().on_delete_connection_request(
2380
+ DeleteConnectionRequest(
2381
+ source_parameter_name=parameter_name,
2382
+ target_parameter_name=proxy_parameter.name,
2383
+ source_node_name=node.name,
2384
+ target_node_name=self.name,
2385
+ )
2386
+ )
2387
+ if delete_result.failed():
2388
+ msg = f"{self.name}: Failed to delete internal outgoing connection from {node.name}.{parameter_name} to proxy {proxy_parameter.name}: {delete_result.result_details}"
2389
+ raise RuntimeError(msg)
2390
+
2391
+ for connection in remap_connections:
2392
+ delete_result = GriptapeNodes.FlowManager().on_delete_connection_request(
2393
+ DeleteConnectionRequest(
2394
+ source_parameter_name=connection.source_parameter.name,
2395
+ target_parameter_name=connection.target_parameter.name,
2396
+ source_node_name=connection.source_node.name,
2397
+ target_node_name=connection.target_node.name,
2398
+ )
2399
+ )
2400
+ if delete_result.failed():
2401
+ msg = f"{self.name}: Failed to delete external connection from proxy {proxy_parameter.name} to {connection.target_node.name}.{connection.target_parameter.name}: {delete_result.result_details}"
2402
+ raise RuntimeError(msg)
2403
+
2404
+ create_result = GriptapeNodes.FlowManager().on_create_connection_request(
2405
+ CreateConnectionRequest(
2406
+ source_parameter_name=parameter_name,
2407
+ target_parameter_name=connection.target_parameter.name,
2408
+ source_node_name=node.name,
2409
+ target_node_name=connection.target_node.name,
2410
+ )
2411
+ )
2412
+ if create_result.failed():
2413
+ msg = f"{self.name}: Failed to create direct outgoing connection from {node.name}.{parameter_name} to {connection.target_node.name}.{connection.target_parameter.name}: {create_result.result_details}"
2414
+ raise RuntimeError(msg)
2127
2415
 
2128
- if conn.target_node.name not in node_names_in_group:
2129
- self.track_external_connection(conn, conn_id, is_incoming=False, grouped_node=node)
2416
+ self._cleanup_proxy_parameter(proxy_parameter, "right_parameters")
2417
+
2418
+ def _remap_incoming_connections(self, node: BaseNode, connections: Connections) -> None:
2419
+ """Remap incoming connections that go through proxy parameters.
2420
+
2421
+ Args:
2422
+ node: The node being added to the group
2423
+ connections: Connections object from FlowManager
2424
+ """
2425
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
2426
+
2427
+ incoming_connections = connections.get_incoming_connections_from_node(node, from_node=self)
2428
+ for parameter_name, incoming_connection_list in incoming_connections.items():
2429
+ for incoming_connection in incoming_connection_list:
2430
+ proxy_parameter = incoming_connection.source_parameter
2431
+ remap_connections = connections.get_incoming_connections_to_parameter(self, proxy_parameter)
2432
+ delete_result = GriptapeNodes.FlowManager().on_delete_connection_request(
2433
+ DeleteConnectionRequest(
2434
+ source_parameter_name=proxy_parameter.name,
2435
+ target_parameter_name=parameter_name,
2436
+ source_node_name=self.name,
2437
+ target_node_name=node.name,
2438
+ )
2439
+ )
2440
+ if delete_result.failed():
2441
+ msg = f"{self.name}: Failed to delete internal incoming connection from proxy {proxy_parameter.name} to {node.name}.{parameter_name}: {delete_result.result_details}"
2442
+ raise RuntimeError(msg)
2443
+
2444
+ for connection in remap_connections:
2445
+ delete_result = GriptapeNodes.FlowManager().on_delete_connection_request(
2446
+ DeleteConnectionRequest(
2447
+ source_parameter_name=connection.source_parameter.name,
2448
+ target_parameter_name=proxy_parameter.name,
2449
+ source_node_name=connection.source_node.name,
2450
+ target_node_name=self.name,
2451
+ )
2452
+ )
2453
+ if delete_result.failed():
2454
+ msg = f"{self.name}: Failed to delete external connection from {connection.source_node.name}.{connection.source_parameter.name} to proxy {proxy_parameter.name}: {delete_result.result_details}"
2455
+ raise RuntimeError(msg)
2456
+
2457
+ create_result = GriptapeNodes.FlowManager().on_create_connection_request(
2458
+ CreateConnectionRequest(
2459
+ source_parameter_name=connection.source_parameter.name,
2460
+ target_parameter_name=parameter_name,
2461
+ source_node_name=connection.source_node.name,
2462
+ target_node_name=node.name,
2463
+ )
2464
+ )
2465
+ if create_result.failed():
2466
+ msg = f"{self.name}: Failed to create direct incoming connection from {connection.source_node.name}.{connection.source_parameter.name} to {node.name}.{parameter_name}: {create_result.result_details}"
2467
+ raise RuntimeError(msg)
2468
+
2469
+ self._cleanup_proxy_parameter(proxy_parameter, "left_parameters")
2470
+
2471
+ def remap_to_internal(self, nodes: list[BaseNode], connections: Connections) -> None:
2472
+ """Remap connections that are now internal after adding nodes to the group.
2473
+
2474
+ When nodes are added to a group, some connections that previously went through
2475
+ proxy parameters may now be internal. This method identifies such connections
2476
+ and restores direct connections between the nodes.
2477
+
2478
+ Args:
2479
+ nodes: List of nodes being added to the group
2480
+ connections: Connections object from FlowManager
2481
+ """
2482
+ for node in nodes:
2483
+ self._remap_outgoing_connections(node, connections)
2484
+ self._remap_incoming_connections(node, connections)
2485
+
2486
+ def after_outgoing_connection_removed(
2487
+ self, source_parameter: Parameter, target_node: BaseNode, target_parameter: Parameter
2488
+ ) -> None:
2489
+ # Instead of right_parameters, we should check the internal connections
2490
+ if target_node.parent_group == self:
2491
+ metadata_key = "left_parameters"
2492
+ else:
2493
+ metadata_key = "right_parameters"
2494
+ self._cleanup_proxy_parameter(source_parameter, metadata_key)
2495
+ return super().after_outgoing_connection_removed(source_parameter, target_node, target_parameter)
2496
+
2497
+ def after_incoming_connection_removed(
2498
+ self, source_node: BaseNode, source_parameter: Parameter, target_parameter: Parameter
2499
+ ) -> None:
2500
+ # Instead of left_parameters, we should check the internal connections.
2501
+ if source_node.parent_group == self:
2502
+ metadata_key = "right_parameters"
2503
+ else:
2504
+ metadata_key = "left_parameters"
2505
+ self._cleanup_proxy_parameter(target_parameter, metadata_key)
2506
+ return super().after_incoming_connection_removed(source_node, source_parameter, target_parameter)
2130
2507
 
2131
2508
  def add_nodes_to_group(self, nodes: list[BaseNode]) -> None:
2132
2509
  """Add nodes to the group and track their connections.
@@ -2134,23 +2511,62 @@ class NodeGroupNode(BaseNode):
2134
2511
  Args:
2135
2512
  nodes: List of nodes to add to the group
2136
2513
  """
2514
+ from griptape_nodes.retained_mode.events.node_events import (
2515
+ MoveNodeToNewFlowRequest,
2516
+ MoveNodeToNewFlowResultSuccess,
2517
+ )
2137
2518
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
2138
2519
 
2139
2520
  self._remove_nodes_from_existing_parents(nodes)
2140
2521
  self._add_nodes_to_group_dict(nodes)
2141
2522
 
2523
+ # Create subflow on-demand if it doesn't exist
2524
+ subflow_name = self.metadata.get("subflow_name")
2525
+ if subflow_name is None:
2526
+ self._create_subflow()
2527
+ subflow_name = self.metadata.get("subflow_name")
2528
+
2529
+ if subflow_name is not None:
2530
+ for node in nodes:
2531
+ move_request = MoveNodeToNewFlowRequest(node_name=node.name, target_flow_name=subflow_name)
2532
+ move_result = GriptapeNodes.handle_request(move_request)
2533
+ if not isinstance(move_result, MoveNodeToNewFlowResultSuccess):
2534
+ msg = "%s failed to move node '%s' to subflow: %s", self.name, node.name, move_result.result_details
2535
+ logger.error(msg)
2536
+ raise RuntimeError(msg) # noqa: TRY004
2537
+
2142
2538
  connections = GriptapeNodes.FlowManager().get_connections()
2143
2539
  node_names_in_group = set(self.nodes.keys())
2144
2540
  self.metadata["node_names_in_group"] = list(node_names_in_group)
2541
+ self.remap_to_internal(nodes, connections)
2542
+ self._map_external_connections_for_nodes(nodes, connections, node_names_in_group)
2145
2543
 
2146
- nodes_being_added = {node.name for node in nodes}
2147
- internal_conns = connections.get_connections_between_nodes(nodes_being_added)
2148
- for conn in internal_conns:
2149
- self.track_internal_connection(conn)
2544
+ def _map_external_connections_for_nodes(
2545
+ self, nodes: list[BaseNode], connections: Connections, node_names_in_group: set[str]
2546
+ ) -> None:
2547
+ """Map external connections for nodes being added to the group.
2150
2548
 
2549
+ Args:
2550
+ nodes: List of nodes being added
2551
+ connections: Connections object from FlowManager
2552
+ node_names_in_group: Set of all node names currently in the group
2553
+ """
2151
2554
  for node in nodes:
2152
- self._track_incoming_connections(node, connections, node_names_in_group)
2153
- self._track_outgoing_connections(node, connections, node_names_in_group)
2555
+ outgoing_connections = connections.get_all_outgoing_connections(node)
2556
+ for conn in outgoing_connections:
2557
+ if conn.target_node.name not in node_names_in_group:
2558
+ self.map_external_connection(
2559
+ conn=conn,
2560
+ is_incoming=False,
2561
+ )
2562
+
2563
+ incoming_connections = connections.get_all_incoming_connections(node)
2564
+ for conn in incoming_connections:
2565
+ if conn.source_node.name not in node_names_in_group:
2566
+ self.map_external_connection(
2567
+ conn=conn,
2568
+ is_incoming=True,
2569
+ )
2154
2570
 
2155
2571
  def _validate_nodes_in_group(self, nodes: list[BaseNode]) -> None:
2156
2572
  """Validate that all nodes are in the group."""
@@ -2159,47 +2575,15 @@ class NodeGroupNode(BaseNode):
2159
2575
  msg = f"Node {node.name} is not in node group {self.name}"
2160
2576
  raise ValueError(msg)
2161
2577
 
2162
- def _untrack_external_incoming_for_node(self, node: BaseNode) -> None:
2163
- """Untrack external incoming connections for a node."""
2164
- for conn in list(self.stored_connections.external_connections.incoming_connections):
2165
- conn_id = id(conn)
2166
- original_target = self.stored_connections.original_targets.incoming_sources.get(conn_id)
2167
- if original_target and original_target.name == node.name:
2168
- self.untrack_external_connection(conn, conn_id, is_incoming=True)
2169
-
2170
- def _untrack_external_outgoing_for_node(self, node: BaseNode) -> None:
2171
- """Untrack external outgoing connections for a node."""
2172
- for conn in list(self.stored_connections.external_connections.outgoing_connections):
2173
- conn_id = id(conn)
2174
- original_source = self.stored_connections.original_targets.outgoing_targets.get(conn_id)
2175
- if original_source and original_source.name == node.name:
2176
- self.untrack_external_connection(conn, conn_id, is_incoming=False)
2177
-
2178
- def _untrack_internal_for_node(self, node: BaseNode, nodes_being_removed: set[str]) -> None:
2179
- """Untrack internal connections for a node."""
2180
- for conn in list(self.stored_connections.internal_connections):
2181
- if node.name not in (conn.source_node.name, conn.target_node.name):
2182
- continue
2183
-
2184
- other_node_name = conn.target_node.name if conn.source_node.name == node.name else conn.source_node.name
2185
- if other_node_name in nodes_being_removed or other_node_name not in self.nodes:
2186
- self.untrack_internal_connection(conn)
2578
+ def delete_nodes_from_group(self, nodes: list[BaseNode]) -> None:
2579
+ """Delete nodes from the group and untrack their connections.
2187
2580
 
2188
- def has_external_control_input(self) -> bool:
2189
- """Check if this NodeGroup has any external incoming control connections.
2190
-
2191
- Returns:
2192
- True if any external incoming connection is a control input, False otherwise
2581
+ Args:
2582
+ nodes: List of nodes to delete from the group
2193
2583
  """
2194
- from griptape_nodes.exe_types.core_types import ParameterTypeBuiltin
2195
-
2196
- for conn in self.stored_connections.external_connections.incoming_connections:
2197
- if conn.target_parameter.type == ParameterTypeBuiltin.CONTROL_TYPE:
2198
- return True
2199
- if ParameterTypeBuiltin.CONTROL_TYPE.value in conn.target_parameter.input_types:
2200
- return True
2201
-
2202
- return False
2584
+ for node in nodes:
2585
+ self.nodes.pop(node.name)
2586
+ self.metadata["node_names_in_group"] = list(self.nodes.keys())
2203
2587
 
2204
2588
  def remove_nodes_from_group(self, nodes: list[BaseNode]) -> None:
2205
2589
  """Remove nodes from the group and untrack their connections.
@@ -2207,24 +2591,48 @@ class NodeGroupNode(BaseNode):
2207
2591
  Args:
2208
2592
  nodes: List of nodes to remove from the group
2209
2593
  """
2594
+ from griptape_nodes.retained_mode.events.node_events import (
2595
+ MoveNodeToNewFlowRequest,
2596
+ MoveNodeToNewFlowResultSuccess,
2597
+ )
2210
2598
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
2211
2599
 
2212
2600
  self._validate_nodes_in_group(nodes)
2213
2601
 
2214
- GriptapeNodes.FlowManager().get_connections()
2215
- nodes_being_removed = {node.name for node in nodes}
2216
-
2217
- for node in nodes:
2218
- self._untrack_external_incoming_for_node(node)
2219
- self._untrack_external_outgoing_for_node(node)
2220
- self._untrack_internal_for_node(node, nodes_being_removed)
2602
+ parent_flow_name = None
2603
+ try:
2604
+ parent_flow_name = GriptapeNodes.NodeManager().get_node_parent_flow_by_name(self.name)
2605
+ except KeyError:
2606
+ logger.warning("%s has no parent flow, cannot move nodes back", self.name)
2221
2607
 
2608
+ connections = GriptapeNodes.FlowManager().get_connections()
2222
2609
  for node in nodes:
2223
2610
  node.parent_group = None
2224
2611
  self.nodes.pop(node.name)
2225
2612
 
2613
+ if parent_flow_name is not None:
2614
+ move_request = MoveNodeToNewFlowRequest(node_name=node.name, target_flow_name=parent_flow_name)
2615
+ move_result = GriptapeNodes.handle_request(move_request)
2616
+ if not isinstance(move_result, MoveNodeToNewFlowResultSuccess):
2617
+ msg = (
2618
+ "%s failed to move node '%s' back to parent flow: %s",
2619
+ self.name,
2620
+ node.name,
2621
+ move_result.result_details,
2622
+ )
2623
+ logger.error(msg)
2624
+ raise RuntimeError(msg)
2625
+
2626
+ for node in nodes:
2627
+ self.unmap_node_connections(node, connections)
2628
+
2226
2629
  self.metadata["node_names_in_group"] = list(self.nodes.keys())
2227
2630
 
2631
+ remaining_nodes = list(self.nodes.values())
2632
+ if remaining_nodes:
2633
+ node_names_in_group = set(self.nodes.keys())
2634
+ self._map_external_connections_for_nodes(remaining_nodes, connections, node_names_in_group)
2635
+
2228
2636
  async def aprocess(self) -> None:
2229
2637
  """Execute all nodes in the group in parallel.
2230
2638
 
@@ -2232,6 +2640,50 @@ class NodeGroupNode(BaseNode):
2232
2640
  group concurrently using asyncio.gather and handles propagating input
2233
2641
  values from the proxy to the grouped nodes.
2234
2642
  """
2643
+ from griptape_nodes.retained_mode.events.execution_events import StartLocalSubflowRequest
2644
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
2645
+
2646
+ subflow = self.metadata.get("subflow_name")
2647
+ if subflow is not None and isinstance(subflow, str):
2648
+ await GriptapeNodes.FlowManager().on_start_local_subflow_request(
2649
+ StartLocalSubflowRequest(flow_name=subflow)
2650
+ )
2651
+
2652
+ # After subflow execution, collect output values from internal nodes
2653
+ # and set them on the NodeGroup's output (right) proxy parameters
2654
+ connections = GriptapeNodes.FlowManager().get_connections()
2655
+
2656
+ # Get all right parameters (output parameters)
2657
+ right_params = self.metadata.get("right_parameters", [])
2658
+ for proxy_param_name in right_params:
2659
+ proxy_param = self.get_parameter_by_name(proxy_param_name)
2660
+ if proxy_param is None:
2661
+ continue
2662
+
2663
+ # Find the internal node connected to this proxy parameter
2664
+ # The internal connection goes: InternalNode -> ProxyParameter
2665
+ incoming_connections = connections.get_incoming_connections_to_parameter(self, proxy_param)
2666
+ if not incoming_connections:
2667
+ continue
2668
+
2669
+ # Get the first connection (there should only be one internal connection)
2670
+ for connection in incoming_connections:
2671
+ if not connection.is_node_group_internal:
2672
+ continue
2673
+
2674
+ # Get the value from the internal node's output parameter
2675
+ internal_node = connection.source_node
2676
+ internal_param = connection.source_parameter
2677
+
2678
+ if internal_param.name in internal_node.parameter_output_values:
2679
+ value = internal_node.parameter_output_values[internal_param.name]
2680
+ else:
2681
+ value = internal_node.get_parameter_value(internal_param.name)
2682
+
2683
+ # Set the value on the NodeGroup's proxy parameter output
2684
+ if value is not None:
2685
+ self.parameter_output_values[proxy_param_name] = value
2686
+ break
2235
2687
 
2236
2688
  def process(self) -> Any:
2237
2689
  """Synchronous process method - not used for proxy nodes."""
@@ -2242,6 +2694,7 @@ class Connection:
2242
2694
  target_node: BaseNode
2243
2695
  source_parameter: Parameter
2244
2696
  target_parameter: Parameter
2697
+ is_node_group_internal: bool
2245
2698
 
2246
2699
  def __init__(
2247
2700
  self,
@@ -2249,11 +2702,14 @@ class Connection:
2249
2702
  source_parameter: Parameter,
2250
2703
  target_node: BaseNode,
2251
2704
  target_parameter: Parameter,
2705
+ *,
2706
+ is_node_group_internal: bool = False,
2252
2707
  ) -> None:
2253
2708
  self.source_node = source_node
2254
2709
  self.target_node = target_node
2255
2710
  self.source_parameter = source_parameter
2256
2711
  self.target_parameter = target_parameter
2712
+ self.is_node_group_internal = is_node_group_internal
2257
2713
 
2258
2714
  def get_target_node(self) -> BaseNode:
2259
2715
  return self.target_node