griptape-nodes 0.66.2__py3-none-any.whl → 0.68.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 (34) hide show
  1. griptape_nodes/bootstrap/utils/python_subprocess_executor.py +17 -4
  2. griptape_nodes/common/node_executor.py +295 -18
  3. griptape_nodes/exe_types/core_types.py +28 -1
  4. griptape_nodes/exe_types/node_groups/__init__.py +2 -2
  5. griptape_nodes/exe_types/node_groups/base_iterative_node_group.py +81 -10
  6. griptape_nodes/exe_types/node_groups/base_node_group.py +64 -1
  7. griptape_nodes/exe_types/node_groups/subflow_node_group.py +0 -34
  8. griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_variant_parameter.py +152 -0
  9. griptape_nodes/exe_types/param_components/seed_parameter.py +3 -2
  10. griptape_nodes/exe_types/param_types/parameter_audio.py +3 -0
  11. griptape_nodes/exe_types/param_types/parameter_bool.py +3 -0
  12. griptape_nodes/exe_types/param_types/parameter_button.py +3 -0
  13. griptape_nodes/exe_types/param_types/parameter_dict.py +151 -0
  14. griptape_nodes/exe_types/param_types/parameter_float.py +3 -0
  15. griptape_nodes/exe_types/param_types/parameter_image.py +3 -0
  16. griptape_nodes/exe_types/param_types/parameter_int.py +3 -0
  17. griptape_nodes/exe_types/param_types/parameter_json.py +268 -0
  18. griptape_nodes/exe_types/param_types/parameter_number.py +3 -0
  19. griptape_nodes/exe_types/param_types/parameter_range.py +393 -0
  20. griptape_nodes/exe_types/param_types/parameter_string.py +3 -0
  21. griptape_nodes/exe_types/param_types/parameter_three_d.py +3 -0
  22. griptape_nodes/exe_types/param_types/parameter_video.py +3 -0
  23. griptape_nodes/retained_mode/events/library_events.py +2 -0
  24. griptape_nodes/retained_mode/events/parameter_events.py +89 -1
  25. griptape_nodes/retained_mode/managers/event_manager.py +176 -10
  26. griptape_nodes/retained_mode/managers/flow_manager.py +2 -1
  27. griptape_nodes/retained_mode/managers/library_manager.py +14 -4
  28. griptape_nodes/retained_mode/managers/node_manager.py +187 -7
  29. griptape_nodes/retained_mode/managers/workflow_manager.py +58 -16
  30. griptape_nodes/utils/file_utils.py +58 -0
  31. {griptape_nodes-0.66.2.dist-info → griptape_nodes-0.68.0.dist-info}/METADATA +1 -1
  32. {griptape_nodes-0.66.2.dist-info → griptape_nodes-0.68.0.dist-info}/RECORD +34 -30
  33. {griptape_nodes-0.66.2.dist-info → griptape_nodes-0.68.0.dist-info}/WHEEL +1 -1
  34. {griptape_nodes-0.66.2.dist-info → griptape_nodes-0.68.0.dist-info}/entry_points.txt +0 -0
@@ -67,9 +67,28 @@ class EventManager:
67
67
  return self._event_queue
68
68
 
69
69
  def should_suppress_event(self, event: BaseEvent | ProgressEvent) -> bool:
70
- """Check if events should be suppressed from being sent to websockets."""
70
+ """Check if events should be suppressed from being sent to websockets.
71
+
72
+ This method checks both the wrapper event type and the payload type for wrapped events.
73
+ For example, if InvolvedNodesEvent is in the suppression set, an ExecutionGriptapeNodeEvent
74
+ that wraps an InvolvedNodesEvent will be suppressed.
75
+ """
71
76
  event_type = type(event)
72
- return self._event_suppression_counts.get(event_type, 0) > 0
77
+
78
+ # Check wrapper type first
79
+ if self._event_suppression_counts.get(event_type, 0) > 0:
80
+ return True
81
+
82
+ # For wrapped events (like ExecutionGriptapeNodeEvent), also check the payload type
83
+ wrapped_event = getattr(event, "wrapped_event", None)
84
+ if wrapped_event is not None:
85
+ payload = getattr(wrapped_event, "payload", None)
86
+ if payload is not None:
87
+ payload_type = type(payload)
88
+ if self._event_suppression_counts.get(payload_type, 0) > 0:
89
+ return True
90
+
91
+ return False
73
92
 
74
93
  def clear_event_suppression(self) -> None:
75
94
  """Clear all event suppression counts."""
@@ -433,11 +452,14 @@ class EventTranslationContext:
433
452
  self.manager = manager
434
453
  self.node_name_mapping = node_name_mapping
435
454
  self.original_put_event: Any = None
455
+ self.original_aput_event: Any = None
436
456
 
437
457
  def __enter__(self) -> None:
438
458
  """Enter the context and start translating events."""
439
459
  self.original_put_event = self.manager.put_event
460
+ self.original_aput_event = self.manager.aput_event
440
461
  self.manager.put_event = self._translate_and_put # type: ignore[method-assign]
462
+ self.manager.aput_event = self._translate_and_aput # type: ignore[method-assign]
441
463
 
442
464
  def __exit__(
443
465
  self,
@@ -447,24 +469,168 @@ class EventTranslationContext:
447
469
  ) -> None:
448
470
  """Exit the context and restore original event sending."""
449
471
  self.manager.put_event = self.original_put_event # type: ignore[method-assign]
472
+ self.manager.aput_event = self.original_aput_event # type: ignore[method-assign]
450
473
 
451
- def _translate_and_put(self, event: Any) -> None:
452
- """Translate node names in events and put them in the queue.
474
+ def _translate_event(self, event: Any) -> Any:
475
+ """Translate node names in an event.
453
476
 
454
477
  Args:
455
- event: The event to potentially translate and send
478
+ event: The event to potentially translate
479
+
480
+ Returns:
481
+ The translated event, or the original if no translation needed
456
482
  """
483
+ # Handle wrapped events (like ExecutionGriptapeNodeEvent)
484
+ wrapped_event = getattr(event, "wrapped_event", None)
485
+ if wrapped_event is not None:
486
+ payload = getattr(wrapped_event, "payload", None)
487
+ if payload is not None:
488
+ translated_payload = self._translate_payload(payload)
489
+ if translated_payload is not payload:
490
+ # Create a new wrapped event with the translated payload
491
+ translated_event = self._create_translated_wrapped_event(event, translated_payload)
492
+ if translated_event is not None:
493
+ return translated_event
494
+
457
495
  # Check if event has node_name attribute and needs translation
458
496
  if hasattr(event, "node_name"):
459
497
  node_name = event.node_name
460
498
  if node_name in self.node_name_mapping:
461
499
  # Create a copy of the event with the translated node name
462
- translated_event = self._copy_event_with_translated_name(event)
463
- self.original_put_event(translated_event)
464
- return
500
+ return self._copy_event_with_translated_name(event)
501
+
502
+ return event
503
+
504
+ def _translate_and_put(self, event: Any) -> None:
505
+ """Translate node names in events and put them in the queue (sync version).
506
+
507
+ Args:
508
+ event: The event to potentially translate and send
509
+ """
510
+ translated_event = self._translate_event(event)
511
+ self.original_put_event(translated_event)
512
+
513
+ async def _translate_and_aput(self, event: Any) -> None:
514
+ """Translate node names in events and put them in the queue (async version).
515
+
516
+ Args:
517
+ event: The event to potentially translate and send
518
+ """
519
+ translated_event = self._translate_event(event)
520
+ await self.original_aput_event(translated_event)
521
+
522
+ def _translate_payload(self, payload: Any) -> Any:
523
+ """Translate node names in a payload.
524
+
525
+ Handles both single node_name and involved_nodes list.
526
+
527
+ Args:
528
+ payload: The payload to translate
529
+
530
+ Returns:
531
+ A new payload with translated names, or the original if no translation needed
532
+ """
533
+ # Handle involved_nodes list (e.g., InvolvedNodesEvent)
534
+ involved_nodes = getattr(payload, "involved_nodes", None)
535
+ if involved_nodes is not None and isinstance(involved_nodes, list):
536
+ translated_nodes: list[str] = []
537
+ any_translated = False
538
+ for node_name in involved_nodes:
539
+ if node_name in self.node_name_mapping:
540
+ translated_nodes.append(self.node_name_mapping[node_name])
541
+ any_translated = True
542
+ else:
543
+ translated_nodes.append(node_name)
544
+ # Only create new payload if something was translated
545
+ if any_translated:
546
+ return self._copy_payload_with_translated_involved_nodes(payload, translated_nodes)
547
+
548
+ # Handle single node_name
549
+ node_name = getattr(payload, "node_name", None)
550
+ if node_name is not None and node_name in self.node_name_mapping:
551
+ return self._copy_payload_with_translated_node_name(payload, self.node_name_mapping[node_name])
552
+
553
+ return payload
554
+
555
+ def _copy_payload_with_translated_involved_nodes(self, payload: Any, translated_nodes: list[str]) -> Any:
556
+ """Create a copy of a payload with translated involved_nodes.
557
+
558
+ Args:
559
+ payload: The payload to copy
560
+ translated_nodes: The translated list of node names
561
+
562
+ Returns:
563
+ A new payload instance with translated involved_nodes
564
+ """
565
+ payload_class = type(payload)
566
+
567
+ if hasattr(payload, "model_dump"):
568
+ payload_dict = payload.model_dump()
569
+ elif hasattr(payload, "__dict__"):
570
+ payload_dict = payload.__dict__.copy()
571
+ else:
572
+ return payload
573
+
574
+ payload_dict["involved_nodes"] = translated_nodes
575
+
576
+ try:
577
+ return payload_class(**payload_dict)
578
+ except Exception:
579
+ return payload
580
+
581
+ def _copy_payload_with_translated_node_name(self, payload: Any, translated_name: str) -> Any:
582
+ """Create a copy of a payload with a translated node_name.
583
+
584
+ Args:
585
+ payload: The payload to copy
586
+ translated_name: The translated node name
587
+
588
+ Returns:
589
+ A new payload instance with translated node_name
590
+ """
591
+ payload_class = type(payload)
592
+
593
+ if hasattr(payload, "model_dump"):
594
+ payload_dict = payload.model_dump()
595
+ elif hasattr(payload, "__dict__"):
596
+ payload_dict = payload.__dict__.copy()
597
+ else:
598
+ return payload
465
599
 
466
- # No translation needed, send as-is
467
- self.original_put_event(event)
600
+ payload_dict["node_name"] = translated_name
601
+
602
+ try:
603
+ return payload_class(**payload_dict)
604
+ except Exception:
605
+ return payload
606
+
607
+ def _create_translated_wrapped_event(self, event: Any, translated_payload: Any) -> Any | None:
608
+ """Create a new wrapped event with a translated payload.
609
+
610
+ Args:
611
+ event: The original wrapped event (e.g., ExecutionGriptapeNodeEvent)
612
+ translated_payload: The translated payload
613
+
614
+ Returns:
615
+ A new wrapped event with the translated payload, or None if creation fails
616
+ """
617
+ wrapped_event = getattr(event, "wrapped_event", None)
618
+ if wrapped_event is None:
619
+ return None
620
+
621
+ # Create new wrapped_event with translated payload
622
+ wrapped_class = type(wrapped_event)
623
+ try:
624
+ new_wrapped = wrapped_class(payload=translated_payload)
625
+ except Exception:
626
+ return None
627
+
628
+ # Create new outer event with new wrapped_event
629
+ event_class = type(event)
630
+ try:
631
+ return event_class(wrapped_event=new_wrapped)
632
+ except Exception:
633
+ return None
468
634
 
469
635
  def _copy_event_with_translated_name(self, event: Any) -> Any:
470
636
  """Create a copy of an event with the node name translated to the original name.
@@ -2319,11 +2319,12 @@ class FlowManager:
2319
2319
  start_node_parameter_value_commands.append(param_value_command)
2320
2320
 
2321
2321
  # Create parameter command for start node (following single-node pattern exactly)
2322
+ # Use source parameter's default value to ensure type-safe propagation during connection creation
2322
2323
  add_param_request = AddParameterToNodeRequest(
2323
2324
  node_name=start_node_name,
2324
2325
  parameter_name=param_name,
2325
2326
  type=source_param.output_type,
2326
- default_value=None,
2327
+ default_value=source_param.default_value,
2327
2328
  tooltip=f"Parameter {target_parameter_name} from node {target_node_name} in packaged flow",
2328
2329
  initial_setup=True,
2329
2330
  )
@@ -170,7 +170,7 @@ from griptape_nodes.retained_mode.managers.os_manager import OSManager
170
170
  from griptape_nodes.retained_mode.managers.settings import LIBRARIES_TO_DOWNLOAD_KEY, LIBRARIES_TO_REGISTER_KEY
171
171
  from griptape_nodes.utils.async_utils import subprocess_run
172
172
  from griptape_nodes.utils.dict_utils import merge_dicts
173
- from griptape_nodes.utils.file_utils import find_file_in_directory
173
+ from griptape_nodes.utils.file_utils import find_file_in_directory, find_files_recursive
174
174
  from griptape_nodes.utils.git_utils import (
175
175
  GitCloneError,
176
176
  GitPullError,
@@ -1244,6 +1244,7 @@ class LibraryManager:
1244
1244
  LibraryRegistry.get_library(name=library_name)
1245
1245
  return RegisterLibraryFromFileResultSuccess(
1246
1246
  library_name=library_name,
1247
+ was_already_loaded=True,
1247
1248
  result_details=f"Library '{library_name}' already loaded",
1248
1249
  )
1249
1250
  except KeyError:
@@ -1278,6 +1279,7 @@ class LibraryManager:
1278
1279
  # Already loaded and good to go
1279
1280
  return RegisterLibraryFromFileResultSuccess(
1280
1281
  library_name=library_info.library_name,
1282
+ was_already_loaded=True,
1281
1283
  result_details=f"Library '{library_info.library_name}' already loaded",
1282
1284
  )
1283
1285
 
@@ -3107,7 +3109,7 @@ class LibraryManager:
3107
3109
  problems=problems,
3108
3110
  )
3109
3111
 
3110
- async def load_libraries_request(self, request: LoadLibrariesRequest) -> ResultPayload: # noqa: ARG002, C901
3112
+ async def load_libraries_request(self, request: LoadLibrariesRequest) -> ResultPayload: # noqa: ARG002, C901, PLR0912
3111
3113
  """Load all libraries from configuration (backward compatibility wrapper).
3112
3114
 
3113
3115
  This is the legacy entry point that loads all configured libraries.
@@ -3154,6 +3156,14 @@ class LibraryManager:
3154
3156
  else:
3155
3157
  library_name = lib_path
3156
3158
 
3159
+ # Check if library was already loaded (skip event emission if so)
3160
+ if isinstance(load_result, RegisterLibraryFromFileResultSuccess) and load_result.was_already_loaded:
3161
+ # Library was already loaded - skip events and continue
3162
+ loaded_count += 1
3163
+ continue
3164
+
3165
+ # Library was actually loaded or failed - emit appropriate events
3166
+
3157
3167
  # Emit loading event
3158
3168
  GriptapeNodes.EventManager().put_event(
3159
3169
  AppEvent(
@@ -3230,8 +3240,8 @@ class LibraryManager:
3230
3240
  def process_path(path: Path) -> None:
3231
3241
  """Process a path, handling both files and directories."""
3232
3242
  if path.is_dir():
3233
- # Process all library JSON files recursively in the directory
3234
- discovered_libraries.update(path.rglob(LibraryManager.LIBRARY_CONFIG_GLOB_PATTERN))
3243
+ # Recursively find library files, skipping hidden directories
3244
+ discovered_libraries.update(find_files_recursive(path, LibraryManager.LIBRARY_CONFIG_GLOB_PATTERN))
3235
3245
  elif path.suffix == ".json":
3236
3246
  discovered_libraries.add(path)
3237
3247
 
@@ -20,6 +20,7 @@ from griptape_nodes.exe_types.core_types import (
20
20
  )
21
21
  from griptape_nodes.exe_types.flow import ControlFlow
22
22
  from griptape_nodes.exe_types.node_groups import SubflowNodeGroup
23
+ from griptape_nodes.exe_types.node_groups.base_node_group import BaseNodeGroup
23
24
  from griptape_nodes.exe_types.node_types import (
24
25
  LOCAL_EXECUTION,
25
26
  PRIVATE_EXECUTION,
@@ -135,12 +136,18 @@ from griptape_nodes.retained_mode.events.object_events import (
135
136
  RenameObjectResultSuccess,
136
137
  )
137
138
  from griptape_nodes.retained_mode.events.parameter_events import (
139
+ AddParameterGroupToNodeRequest,
140
+ AddParameterGroupToNodeResultFailure,
141
+ AddParameterGroupToNodeResultSuccess,
138
142
  AddParameterToNodeRequest,
139
143
  AddParameterToNodeResultFailure,
140
144
  AddParameterToNodeResultSuccess,
141
145
  AlterParameterDetailsRequest,
142
146
  AlterParameterDetailsResultFailure,
143
147
  AlterParameterDetailsResultSuccess,
148
+ AlterParameterGroupDetailsRequest,
149
+ AlterParameterGroupDetailsResultFailure,
150
+ AlterParameterGroupDetailsResultSuccess,
144
151
  GetCompatibleParametersRequest,
145
152
  GetCompatibleParametersResultFailure,
146
153
  GetCompatibleParametersResultSuccess,
@@ -239,6 +246,12 @@ class NodeManager:
239
246
  ListParametersOnNodeRequest, self.on_list_parameters_on_node_request
240
247
  )
241
248
  event_manager.assign_manager_to_request_type(AddParameterToNodeRequest, self.on_add_parameter_to_node_request)
249
+ event_manager.assign_manager_to_request_type(
250
+ AddParameterGroupToNodeRequest, self.on_add_parameter_group_to_node_request
251
+ )
252
+ event_manager.assign_manager_to_request_type(
253
+ AlterParameterGroupDetailsRequest, self.on_alter_parameter_group_details_request
254
+ )
242
255
  event_manager.assign_manager_to_request_type(
243
256
  RemoveParameterFromNodeRequest, self.on_remove_parameter_from_node_request
244
257
  )
@@ -487,9 +500,9 @@ class NodeManager:
487
500
  node.end_node = end_node
488
501
  end_node.start_node = node
489
502
 
490
- # Handle node_names_to_add for SubflowNodeGroup nodes
503
+ # Handle node_names_to_add for BaseNodeGroup nodes
491
504
  if request.node_names_to_add:
492
- if isinstance(node, SubflowNodeGroup):
505
+ if isinstance(node, BaseNodeGroup):
493
506
  nodes_to_add = []
494
507
  for node_name in request.node_names_to_add:
495
508
  try:
@@ -510,7 +523,7 @@ class NodeManager:
510
523
  else:
511
524
  warning_details = (
512
525
  f"Attempted to add nodes '{request.node_names_to_add}' to Node '{node.name}'. "
513
- f"Failed because node is not a SubflowNodeGroup."
526
+ f"Failed because node is not a BaseNodeGroup."
514
527
  )
515
528
  logger.warning(warning_details)
516
529
 
@@ -570,7 +583,7 @@ class NodeManager:
570
583
 
571
584
  def _get_node_group(
572
585
  self, node_group_name: str, node_names: list[str]
573
- ) -> SubflowNodeGroup | AddNodesToNodeGroupResultFailure:
586
+ ) -> BaseNodeGroup | AddNodesToNodeGroupResultFailure:
574
587
  """Get the NodeGroup node."""
575
588
  try:
576
589
  node_group = GriptapeNodes.ObjectManager().get_object_by_name(node_group_name)
@@ -578,7 +591,7 @@ class NodeManager:
578
591
  details = f"Attempted to add nodes '{node_names}' to NodeGroup '{node_group_name}'. Failed because NodeGroup was not found."
579
592
  return AddNodesToNodeGroupResultFailure(result_details=details)
580
593
 
581
- if not isinstance(node_group, SubflowNodeGroup):
594
+ if not isinstance(node_group, BaseNodeGroup):
582
595
  details = f"Attempted to add nodes '{node_names}' to '{node_group_name}'. Failed because '{node_group_name}' is not a NodeGroup."
583
596
  return AddNodesToNodeGroupResultFailure(result_details=details)
584
597
 
@@ -657,7 +670,7 @@ class NodeManager:
657
670
 
658
671
  def _get_node_group_for_remove(
659
672
  self, node_group_name: str, node_names: list[str]
660
- ) -> SubflowNodeGroup | RemoveNodeFromNodeGroupResultFailure:
673
+ ) -> BaseNodeGroup | RemoveNodeFromNodeGroupResultFailure:
661
674
  """Get the NodeGroup node for remove operation."""
662
675
  try:
663
676
  node_group = GriptapeNodes.ObjectManager().get_object_by_name(node_group_name)
@@ -665,7 +678,7 @@ class NodeManager:
665
678
  details = f"Attempted to remove nodes '{node_names}' from NodeGroup '{node_group_name}'. Failed because NodeGroup was not found."
666
679
  return RemoveNodeFromNodeGroupResultFailure(result_details=details)
667
680
 
668
- if not isinstance(node_group, SubflowNodeGroup):
681
+ if not isinstance(node_group, BaseNodeGroup):
669
682
  details = f"Attempted to remove nodes '{node_names}' from '{node_group_name}'. Failed because '{node_group_name}' is not a NodeGroup."
670
683
  return RemoveNodeFromNodeGroupResultFailure(result_details=details)
671
684
 
@@ -1418,6 +1431,72 @@ class NodeManager:
1418
1431
  )
1419
1432
  return result
1420
1433
 
1434
+ def on_add_parameter_group_to_node_request( # noqa: C901, PLR0911
1435
+ self, request: AddParameterGroupToNodeRequest
1436
+ ) -> ResultPayload:
1437
+ """Handle request to add a ParameterGroup to a node."""
1438
+ node_name = request.node_name
1439
+ node = None
1440
+ parent_group: ParameterGroup | None = None
1441
+
1442
+ if node_name is None:
1443
+ if not GriptapeNodes.ContextManager().has_current_node():
1444
+ details = "Attempted to add ParameterGroup to a Node from the Current Context. Failed because the Current Context is empty."
1445
+ return AddParameterGroupToNodeResultFailure(result_details=details)
1446
+
1447
+ node = GriptapeNodes.ContextManager().get_current_node()
1448
+ node_name = node.name
1449
+
1450
+ if node is None:
1451
+ obj_mgr = GriptapeNodes.ObjectManager()
1452
+ node = obj_mgr.attempt_get_object_by_name_as_type(node_name, BaseNode)
1453
+ if node is None:
1454
+ details = f"Attempted to add ParameterGroup '{request.group_name}' to a Node '{node_name}', but no such Node was found."
1455
+ return AddParameterGroupToNodeResultFailure(result_details=details)
1456
+
1457
+ if node.lock:
1458
+ details = f"Attempted to add ParameterGroup '{request.group_name}' to Node '{node_name}'. Failed because the Node was locked."
1459
+ return AddParameterGroupToNodeResultFailure(result_details=details)
1460
+
1461
+ if not request.group_name:
1462
+ details = (
1463
+ f"Attempted to add ParameterGroup to node '{node_name}'. Failed because group_name was not defined."
1464
+ )
1465
+ return AddParameterGroupToNodeResultFailure(result_details=details)
1466
+
1467
+ existing_element = node.get_element_by_name_and_type(request.group_name)
1468
+ if existing_element is not None:
1469
+ details = f"Attempted to add ParameterGroup '{request.group_name}' to node '{node_name}'. Failed because an element with that name already exists."
1470
+ return AddParameterGroupToNodeResultFailure(result_details=details)
1471
+
1472
+ if request.parent_element_name is not None:
1473
+ parent_element = node.get_element_by_name_and_type(request.parent_element_name)
1474
+ if parent_element is None:
1475
+ details = f"Attempted to add ParameterGroup '{request.group_name}' to Parent Element '{request.parent_element_name}' in node '{node_name}'. Failed because parent element didn't exist."
1476
+ return AddParameterGroupToNodeResultFailure(result_details=details)
1477
+
1478
+ if isinstance(parent_element, ParameterGroup):
1479
+ parent_group = parent_element
1480
+
1481
+ new_group = ParameterGroup(
1482
+ name=request.group_name,
1483
+ ui_options=request.ui_options if request.ui_options else {},
1484
+ parent_group_name=parent_group.name if parent_group is not None else None,
1485
+ user_defined=request.is_user_defined,
1486
+ )
1487
+
1488
+ if parent_group is not None:
1489
+ parent_group.add_child(new_group)
1490
+ else:
1491
+ node.add_node_element(new_group)
1492
+
1493
+ details = f"Successfully added ParameterGroup '{request.group_name}' to Node '{node_name}'."
1494
+ logger.debug(details)
1495
+
1496
+ return AddParameterGroupToNodeResultSuccess(
1497
+ group_name=new_group.name, node_name=node_name, result_details=details
1498
+ )
1499
+
1421
1500
  def on_remove_parameter_from_node_request(self, request: RemoveParameterFromNodeRequest) -> ResultPayload: # noqa: C901, PLR0911, PLR0912, PLR0915
1422
1501
  node_name = request.node_name
1423
1502
  node = None
@@ -1592,6 +1671,7 @@ class NodeManager:
1592
1671
  mode_allowed_output=allows_output,
1593
1672
  is_user_defined=getattr(element, "user_defined", False),
1594
1673
  settable=getattr(element, "settable", None),
1674
+ private=getattr(element, "private", False),
1595
1675
  ui_options=getattr(element, "ui_options", None),
1596
1676
  result_details=details,
1597
1677
  )
@@ -1845,6 +1925,55 @@ class NodeManager:
1845
1925
  result = AlterParameterDetailsResultSuccess(result_details=details)
1846
1926
  return result
1847
1927
 
1928
+ def on_alter_parameter_group_details_request( # noqa: PLR0911
1929
+ self, request: AlterParameterGroupDetailsRequest
1930
+ ) -> ResultPayload:
1931
+ """Handle requests to alter ParameterGroup details (primarily ui_options)."""
1932
+ node_name = request.node_name
1933
+ node = None
1934
+
1935
+ if node_name is None:
1936
+ if not GriptapeNodes.ContextManager().has_current_node():
1937
+ details = f"Attempted to alter details for ParameterGroup '{request.group_name}' from node in the Current Context. Failed because there was no such Node."
1938
+ return AlterParameterGroupDetailsResultFailure(result_details=details)
1939
+ node = GriptapeNodes.ContextManager().get_current_node()
1940
+ node_name = node.name
1941
+
1942
+ if node is None:
1943
+ obj_mgr = GriptapeNodes.ObjectManager()
1944
+ node = obj_mgr.attempt_get_object_by_name_as_type(node_name, BaseNode)
1945
+ if node is None:
1946
+ details = f"Attempted to alter details for ParameterGroup '{request.group_name}' from Node '{node_name}', but no such Node was found."
1947
+ return AlterParameterGroupDetailsResultFailure(result_details=details)
1948
+
1949
+ if node.lock:
1950
+ details = f"Attempted to alter details for ParameterGroup '{request.group_name}' from Node '{node_name}'. Failed because the Node was locked."
1951
+ return AlterParameterGroupDetailsResultFailure(result_details=details)
1952
+
1953
+ # Handle ErrorProxyNode parameter group alteration requests
1954
+ if isinstance(node, ErrorProxyNode):
1955
+ if request.initial_setup:
1956
+ node.record_initialization_request(request)
1957
+ details = f"ParameterGroup '{request.group_name}' alteration recorded for ErrorProxyNode '{node_name}'. Original node '{node.original_node_type}' had loading errors - preserving changes for correct recreation when dependency '{node.original_library_name}' is resolved."
1958
+ result_details = ResultDetails(message=details, level=logging.WARNING)
1959
+ return AlterParameterGroupDetailsResultSuccess(result_details=result_details)
1960
+
1961
+ details = f"Cannot modify ParameterGroup '{request.group_name}' on placeholder node '{node_name}'. This placeholder preserves your workflow structure but doesn't allow modifications."
1962
+ return AlterParameterGroupDetailsResultFailure(result_details=details)
1963
+
1964
+ # Find the ParameterGroup
1965
+ group = node.get_element_by_name_and_type(request.group_name, ParameterGroup)
1966
+ if group is None or not isinstance(group, ParameterGroup):
1967
+ details = f"Attempted to alter details for ParameterGroup '{request.group_name}' from Node '{node_name}'. Failed because no such ParameterGroup was found."
1968
+ return AlterParameterGroupDetailsResultFailure(result_details=details)
1969
+
1970
+ # Update ui_options if provided
1971
+ if request.ui_options is not None:
1972
+ group.ui_options = request.ui_options
1973
+
1974
+ details = f"Successfully altered details for ParameterGroup '{request.group_name}' from Node '{node_name}'."
1975
+ return AlterParameterGroupDetailsResultSuccess(result_details=details)
1976
+
1848
1977
  # For C901 (too complex): Need to give customers explicit reasons for failure on each case.
1849
1978
  def on_get_parameter_value_request(self, request: GetParameterValueRequest) -> ResultPayload:
1850
1979
  node_name = request.node_name
@@ -2555,6 +2684,22 @@ class NodeManager:
2555
2684
 
2556
2685
  # Now creation or alteration of all of the elements.
2557
2686
  element_modification_commands = []
2687
+
2688
+ # Serialize only user-defined ParameterGroups (like parameters)
2689
+ all_groups = node.root_ui_element.find_elements_by_type(ParameterGroup)
2690
+ for group in all_groups:
2691
+ if group.user_defined:
2692
+ add_group_request = AddParameterGroupToNodeRequest(
2693
+ node_name=node_name,
2694
+ group_name=group.name,
2695
+ parent_element_name=group.parent_group_name,
2696
+ ui_options=group.ui_options if group.ui_options else {},
2697
+ is_user_defined=True,
2698
+ initial_setup=True,
2699
+ )
2700
+ element_modification_commands.append(add_group_request)
2701
+
2702
+ # Then serialize parameters
2558
2703
  for parameter in node.parameters:
2559
2704
  # Create the parameter, or alter it on the existing node
2560
2705
  if parameter.user_defined:
@@ -2595,6 +2740,23 @@ class NodeManager:
2595
2740
  alter_param_request = AlterParameterDetailsRequest.create(**diff)
2596
2741
  element_modification_commands.append(alter_param_request)
2597
2742
 
2743
+ # Check for ParameterGroup alterations (ui_options changes like collapsed state)
2744
+ if reference_node is not None and not isinstance(node, ErrorProxyNode):
2745
+ # Compare ALL groups against the reference node (not just user-defined)
2746
+ # This matches the pattern used for parameter alterations
2747
+ for group in all_groups:
2748
+ diff = NodeManager._manage_alter_group_details(group, reference_node)
2749
+ relevant = False
2750
+ for key in diff:
2751
+ if key in AlterParameterGroupDetailsRequest.relevant_parameters():
2752
+ relevant = True
2753
+ break
2754
+ if relevant:
2755
+ diff["group_name"] = group.name
2756
+ diff["initial_setup"] = True
2757
+ alter_group_request = AlterParameterGroupDetailsRequest(**diff)
2758
+ element_modification_commands.append(alter_group_request)
2759
+
2598
2760
  # Now assignment of values to all of the parameters.
2599
2761
  set_value_commands = []
2600
2762
 
@@ -2977,6 +3139,24 @@ class NodeManager:
2977
3139
  return vars(parameter)
2978
3140
  return diff
2979
3141
 
3142
+ @staticmethod
3143
+ def _manage_alter_group_details(group: ParameterGroup, base_node_obj: BaseNode) -> dict:
3144
+ """Compare a ParameterGroup against its base version and return differences.
3145
+
3146
+ Args:
3147
+ group: The current ParameterGroup to compare
3148
+ base_node_obj: The reference node containing the base version
3149
+
3150
+ Returns:
3151
+ Dictionary of differences, or empty dict if no changes
3152
+ """
3153
+ base_group = base_node_obj.get_element_by_name_and_type(group.name, ParameterGroup)
3154
+ if base_group and isinstance(base_group, ParameterGroup):
3155
+ diff = base_group.equals(group)
3156
+ else:
3157
+ return {"ui_options": group.ui_options}
3158
+ return diff
3159
+
2980
3160
  @staticmethod
2981
3161
  def _handle_value_hashing( # noqa: PLR0913
2982
3162
  value: Any,