griptape-nodes 0.55.1__py3-none-any.whl → 0.56.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 (60) hide show
  1. griptape_nodes/app/app.py +10 -15
  2. griptape_nodes/app/watch.py +35 -67
  3. griptape_nodes/bootstrap/utils/__init__.py +1 -0
  4. griptape_nodes/bootstrap/utils/python_subprocess_executor.py +122 -0
  5. griptape_nodes/bootstrap/workflow_executors/local_session_workflow_executor.py +418 -0
  6. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +37 -8
  7. griptape_nodes/bootstrap/workflow_executors/subprocess_workflow_executor.py +326 -0
  8. griptape_nodes/bootstrap/workflow_executors/utils/__init__.py +1 -0
  9. griptape_nodes/bootstrap/workflow_executors/utils/subprocess_script.py +51 -0
  10. griptape_nodes/bootstrap/workflow_publishers/__init__.py +1 -0
  11. griptape_nodes/bootstrap/workflow_publishers/local_workflow_publisher.py +43 -0
  12. griptape_nodes/bootstrap/workflow_publishers/subprocess_workflow_publisher.py +84 -0
  13. griptape_nodes/bootstrap/workflow_publishers/utils/__init__.py +1 -0
  14. griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +54 -0
  15. griptape_nodes/cli/commands/engine.py +4 -15
  16. griptape_nodes/cli/commands/init.py +88 -0
  17. griptape_nodes/cli/commands/models.py +2 -0
  18. griptape_nodes/cli/main.py +6 -1
  19. griptape_nodes/cli/shared.py +1 -0
  20. griptape_nodes/exe_types/core_types.py +130 -0
  21. griptape_nodes/exe_types/node_types.py +125 -13
  22. griptape_nodes/machines/control_flow.py +10 -0
  23. griptape_nodes/machines/dag_builder.py +21 -2
  24. griptape_nodes/machines/parallel_resolution.py +25 -10
  25. griptape_nodes/node_library/workflow_registry.py +73 -3
  26. griptape_nodes/retained_mode/events/agent_events.py +2 -0
  27. griptape_nodes/retained_mode/events/base_events.py +18 -17
  28. griptape_nodes/retained_mode/events/execution_events.py +15 -3
  29. griptape_nodes/retained_mode/events/flow_events.py +63 -7
  30. griptape_nodes/retained_mode/events/mcp_events.py +363 -0
  31. griptape_nodes/retained_mode/events/node_events.py +3 -4
  32. griptape_nodes/retained_mode/events/resource_events.py +290 -0
  33. griptape_nodes/retained_mode/events/workflow_events.py +57 -2
  34. griptape_nodes/retained_mode/griptape_nodes.py +17 -1
  35. griptape_nodes/retained_mode/managers/agent_manager.py +67 -4
  36. griptape_nodes/retained_mode/managers/event_manager.py +31 -13
  37. griptape_nodes/retained_mode/managers/flow_manager.py +731 -33
  38. griptape_nodes/retained_mode/managers/library_manager.py +15 -23
  39. griptape_nodes/retained_mode/managers/mcp_manager.py +364 -0
  40. griptape_nodes/retained_mode/managers/model_manager.py +184 -83
  41. griptape_nodes/retained_mode/managers/node_manager.py +15 -4
  42. griptape_nodes/retained_mode/managers/os_manager.py +118 -1
  43. griptape_nodes/retained_mode/managers/resource_components/__init__.py +1 -0
  44. griptape_nodes/retained_mode/managers/resource_components/capability_field.py +41 -0
  45. griptape_nodes/retained_mode/managers/resource_components/comparator.py +18 -0
  46. griptape_nodes/retained_mode/managers/resource_components/resource_instance.py +236 -0
  47. griptape_nodes/retained_mode/managers/resource_components/resource_type.py +79 -0
  48. griptape_nodes/retained_mode/managers/resource_manager.py +306 -0
  49. griptape_nodes/retained_mode/managers/resource_types/__init__.py +1 -0
  50. griptape_nodes/retained_mode/managers/resource_types/cpu_resource.py +108 -0
  51. griptape_nodes/retained_mode/managers/resource_types/os_resource.py +87 -0
  52. griptape_nodes/retained_mode/managers/settings.py +45 -0
  53. griptape_nodes/retained_mode/managers/sync_manager.py +10 -3
  54. griptape_nodes/retained_mode/managers/workflow_manager.py +447 -263
  55. griptape_nodes/traits/multi_options.py +5 -1
  56. griptape_nodes/traits/options.py +10 -2
  57. {griptape_nodes-0.55.1.dist-info → griptape_nodes-0.56.1.dist-info}/METADATA +2 -2
  58. {griptape_nodes-0.55.1.dist-info → griptape_nodes-0.56.1.dist-info}/RECORD +60 -37
  59. {griptape_nodes-0.55.1.dist-info → griptape_nodes-0.56.1.dist-info}/WHEEL +1 -1
  60. {griptape_nodes-0.55.1.dist-info → griptape_nodes-0.56.1.dist-info}/entry_points.txt +0 -0
@@ -55,6 +55,10 @@ def init_command( # noqa: PLR0913
55
55
  bool,
56
56
  typer.Option(help="Run init in non-interactive mode (no prompts)."),
57
57
  ] = False,
58
+ hf_token: Annotated[
59
+ str | None,
60
+ typer.Option(help="Set the Hugging Face token for downloading gated models."),
61
+ ] = None,
58
62
  config: Annotated[
59
63
  list[str] | None,
60
64
  typer.Option(
@@ -83,6 +87,7 @@ def init_command( # noqa: PLR0913
83
87
  secret_values=secret_values,
84
88
  libraries_sync=libraries_sync,
85
89
  bucket_name=bucket_name,
90
+ hf_token=hf_token,
86
91
  )
87
92
  )
88
93
 
@@ -128,6 +133,7 @@ def _run_init_configuration(config: InitConfig) -> None:
128
133
  _handle_workspace_config(config)
129
134
  _handle_storage_backend_config(config)
130
135
  _handle_bucket_config(config)
136
+ _handle_hf_token_config(config)
131
137
  _handle_advanced_library_config(config)
132
138
  _handle_arbitrary_configs(config)
133
139
 
@@ -193,6 +199,25 @@ def _handle_bucket_config(config: InitConfig) -> str | None:
193
199
  return bucket_id
194
200
 
195
201
 
202
+ def _handle_hf_token_config(config: InitConfig) -> str | None:
203
+ """Handle Hugging Face token configuration step."""
204
+ hf_token = None
205
+
206
+ if config.interactive:
207
+ # First ask if they want to configure an HF token
208
+ configure_hf_token = _prompt_for_hf_token_configuration()
209
+ if configure_hf_token:
210
+ hf_token = _prompt_for_hf_token(default_hf_token=config.hf_token)
211
+ elif config.hf_token is not None:
212
+ hf_token = config.hf_token
213
+
214
+ if hf_token is not None:
215
+ secrets_manager.set_secret("HF_TOKEN", hf_token)
216
+ console.print("[bold green]Hugging Face token set[/bold green]")
217
+
218
+ return hf_token
219
+
220
+
196
221
  def _handle_advanced_library_config(config: InitConfig) -> bool | None:
197
222
  """Handle advanced library configuration step."""
198
223
  register_advanced_library = config.register_advanced_library
@@ -513,6 +538,69 @@ def _create_new_bucket(bucket_name: str) -> str:
513
538
  return bucket_id
514
539
 
515
540
 
541
+ def _prompt_for_hf_token_configuration() -> bool:
542
+ """Prompts the user whether to configure a Hugging Face token."""
543
+ # Check if there's already an HF token configured
544
+ current_hf_token = secrets_manager.get_secret("HF_TOKEN", should_error_on_not_found=False)
545
+
546
+ if current_hf_token:
547
+ explainer = """[bold cyan]Hugging Face Token Configuration[/bold cyan]
548
+ You currently have a Hugging Face token configured.
549
+
550
+ Hugging Face tokens are used to access gated models from the Hugging Face Hub, such as:
551
+ - Meta's Llama models
552
+ - black-forest-labs/FLUX.1-dev
553
+ - Other restricted or premium models
554
+
555
+ Would you like to update your Hugging Face token or keep the current one?"""
556
+ prompt_text = "Update Hugging Face token?"
557
+ default_value = False
558
+ else:
559
+ explainer = """[bold cyan]Hugging Face Token Configuration[/bold cyan]
560
+ Would you like to configure a Hugging Face token?
561
+
562
+ Hugging Face tokens are used by the model manager to download gated models from the Hugging Face Hub, such as:
563
+ - Meta's Llama models
564
+ - black-forest-labs/FLUX.1-dev
565
+ - Other restricted or premium models
566
+
567
+ If you don't plan to use gated models, you can skip this step.
568
+ You can get a token from https://huggingface.co/settings/tokens
569
+
570
+ You can always configure a token later by running the initialization process again."""
571
+ prompt_text = "Configure Hugging Face token?"
572
+ default_value = False
573
+
574
+ console.print(Panel(explainer, expand=False))
575
+ return Confirm.ask(prompt_text, default=default_value)
576
+
577
+
578
+ def _prompt_for_hf_token(default_hf_token: str | None = None) -> str | None:
579
+ """Prompts the user for their Hugging Face token."""
580
+ if default_hf_token is None:
581
+ default_hf_token = secrets_manager.get_secret("HF_TOKEN", should_error_on_not_found=False)
582
+
583
+ explainer = """[bold cyan]Hugging Face Token[/bold cyan]
584
+ Please enter your Hugging Face token to enable downloading of gated models.
585
+
586
+ To get a token:
587
+ 1. Go to https://huggingface.co/settings/tokens
588
+ 2. Create a new token with 'Read' permissions
589
+ 3. Copy and paste the token here
590
+
591
+ You can leave this blank to skip token configuration."""
592
+ console.print(Panel(explainer, expand=False))
593
+
594
+ hf_token = Prompt.ask(
595
+ "Hugging Face Token (optional)",
596
+ default=default_hf_token or "",
597
+ show_default=False,
598
+ )
599
+
600
+ # Return None if empty string
601
+ return hf_token if hf_token.strip() else None
602
+
603
+
516
604
  def _parse_key_value_pairs(pairs: list[str] | None) -> dict[str, Any] | None:
517
605
  """Parse key=value pairs from a list of strings.
518
606
 
@@ -1,6 +1,7 @@
1
1
  """Models command for managing AI models."""
2
2
 
3
3
  import asyncio
4
+ import sys
4
5
  from typing import TYPE_CHECKING
5
6
 
6
7
  import typer
@@ -130,6 +131,7 @@ async def _download_model(
130
131
  except Exception as e:
131
132
  console.print("[bold red]Model download failed:[/bold red]")
132
133
  console.print(f"[red]{e}[/red]")
134
+ sys.exit(1)
133
135
 
134
136
 
135
137
  async def _list_models() -> None:
@@ -10,6 +10,7 @@ from rich.console import Console
10
10
  sys.path.append(str(Path.cwd()))
11
11
 
12
12
  from griptape_nodes.cli.commands import config, engine, init, libraries, models, self
13
+ from griptape_nodes.cli.commands.engine import _auto_update_self
13
14
  from griptape_nodes.utils.version_utils import get_complete_version_string
14
15
 
15
16
  console = Console()
@@ -47,9 +48,13 @@ def main(
47
48
  console.print(f"[bold green]{version_string}[/bold green]")
48
49
  raise typer.Exit
49
50
 
51
+ # Run auto-update check for any command (unless disabled)
52
+ if not no_update:
53
+ _auto_update_self()
54
+
50
55
  if ctx.invoked_subcommand is None:
51
56
  # Default to engine command when no subcommand is specified
52
- engine.engine_command(no_update=no_update)
57
+ engine.engine_command()
53
58
 
54
59
 
55
60
  if __name__ == "__main__":
@@ -22,6 +22,7 @@ class InitConfig:
22
22
  secret_values: dict[str, str] | None = None
23
23
  libraries_sync: bool | None = None
24
24
  bucket_name: str | None = None
25
+ hf_token: str | None = None
25
26
 
26
27
 
27
28
  # Initialize console
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import uuid
4
5
  from abc import ABC, abstractmethod
5
6
  from copy import deepcopy
@@ -9,6 +10,8 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, NamedTuple, Self, Type
9
10
 
10
11
  from pydantic import BaseModel
11
12
 
13
+ logger = logging.getLogger("griptape_nodes")
14
+
12
15
 
13
16
  class NodeMessagePayload(BaseModel):
14
17
  """Structured payload for node messages.
@@ -554,6 +557,7 @@ class ParameterMessage(BaseNodeElement, UIOptionsMixin):
554
557
  button_align: ButtonAlignType = "full-width",
555
558
  full_width: bool = False,
556
559
  ui_options: dict | None = None,
560
+ traits: set[Trait.__class__ | Trait] | None = None,
557
561
  **kwargs,
558
562
  ):
559
563
  super().__init__(element_type=ParameterMessage.__name__, **kwargs)
@@ -569,6 +573,17 @@ class ParameterMessage(BaseNodeElement, UIOptionsMixin):
569
573
  self._full_width = full_width
570
574
  self._ui_options = ui_options or {}
571
575
 
576
+ # Handle traits if provided
577
+ if traits:
578
+ for trait in traits:
579
+ if isinstance(trait, type):
580
+ # It's a trait class, instantiate it
581
+ trait_instance = trait()
582
+ else:
583
+ # It's already a trait instance
584
+ trait_instance = trait
585
+ self.add_child(trait_instance)
586
+
572
587
  @property
573
588
  def variant(self) -> VariantType:
574
589
  return self._variant
@@ -694,6 +709,16 @@ class ParameterMessage(BaseNodeElement, UIOptionsMixin):
694
709
  else:
695
710
  button_icon = self.button_icon
696
711
 
712
+ # Check if there are any Button traits with on_click callbacks
713
+ has_button_callback = False
714
+ for child in self.children:
715
+ # Import here to avoid circular imports
716
+ from griptape_nodes.traits.button import Button
717
+
718
+ if isinstance(child, Button) and child.on_click_callback is not None:
719
+ has_button_callback = True
720
+ break
721
+
697
722
  # Merge the UI options with the message-specific options
698
723
  # Always include these fields, even if they're None or empty
699
724
  message_ui_options = {
@@ -705,6 +730,7 @@ class ParameterMessage(BaseNodeElement, UIOptionsMixin):
705
730
  "button_icon": button_icon,
706
731
  "button_variant": self.button_variant,
707
732
  "button_align": self.button_align,
733
+ "button_on_click": has_button_callback,
708
734
  "full_width": self.full_width,
709
735
  }
710
736
 
@@ -1483,12 +1509,23 @@ class ParameterList(ParameterContainer):
1483
1509
  user_defined: bool = False,
1484
1510
  element_id: str | None = None,
1485
1511
  element_type: str | None = None,
1512
+ # UI convenience parameters
1513
+ collapsed: bool | None = None,
1514
+ child_prefix: str | None = None,
1515
+ grid: bool | None = None,
1516
+ grid_columns: int | None = None,
1486
1517
  ):
1487
1518
  if traits:
1488
1519
  self._original_traits = traits
1489
1520
  else:
1490
1521
  self._original_traits = set()
1491
1522
 
1523
+ # Store the UI convenience parameters
1524
+ self._collapsed = collapsed
1525
+ self._child_prefix = child_prefix
1526
+ self._grid = grid
1527
+ self._grid_columns = grid_columns
1528
+
1492
1529
  # Remember: we're a Parameter, too, just like everybody else.
1493
1530
  super().__init__(
1494
1531
  name=name,
@@ -1511,6 +1548,99 @@ class ParameterList(ParameterContainer):
1511
1548
  element_type=element_type,
1512
1549
  )
1513
1550
 
1551
+ @property
1552
+ def collapsed(self) -> bool | None:
1553
+ return self._collapsed
1554
+
1555
+ @collapsed.setter
1556
+ @BaseNodeElement.emits_update_on_write
1557
+ def collapsed(self, value: bool | None) -> None:
1558
+ self._collapsed = value
1559
+
1560
+ @property
1561
+ def child_prefix(self) -> str | None:
1562
+ return self._child_prefix
1563
+
1564
+ @child_prefix.setter
1565
+ @BaseNodeElement.emits_update_on_write
1566
+ def child_prefix(self, value: str | None) -> None:
1567
+ self._child_prefix = value
1568
+
1569
+ @property
1570
+ def grid(self) -> bool | None:
1571
+ return self._grid
1572
+
1573
+ @grid.setter
1574
+ @BaseNodeElement.emits_update_on_write
1575
+ def grid(self, value: bool | None) -> None:
1576
+ self._grid = value
1577
+
1578
+ @property
1579
+ def grid_columns(self) -> int | None:
1580
+ return self._grid_columns
1581
+
1582
+ @grid_columns.setter
1583
+ @BaseNodeElement.emits_update_on_write
1584
+ def grid_columns(self, value: int | None) -> None:
1585
+ self._grid_columns = value
1586
+
1587
+ @property
1588
+ def ui_options(self) -> dict:
1589
+ """Override ui_options to merge convenience parameters in real-time."""
1590
+ # Get base ui_options from parent
1591
+ base_ui_options = super().ui_options
1592
+
1593
+ # Build convenience options from instance parameters
1594
+ convenience_options = {}
1595
+
1596
+ if self._collapsed is not None:
1597
+ convenience_options["collapsed"] = self._collapsed
1598
+
1599
+ if self._child_prefix is not None:
1600
+ convenience_options["child_prefix"] = self._child_prefix
1601
+
1602
+ if self._grid is not None and self._grid:
1603
+ convenience_options["display"] = "grid"
1604
+
1605
+ if self._grid_columns is not None and self._grid:
1606
+ convenience_options["columns"] = self._grid_columns
1607
+
1608
+ # Merge convenience options with base ui_options
1609
+ return {
1610
+ **base_ui_options,
1611
+ **convenience_options,
1612
+ }
1613
+
1614
+ @ui_options.setter
1615
+ @BaseNodeElement.emits_update_on_write
1616
+ def ui_options(self, value: dict) -> None:
1617
+ """Set ui_options, preserving convenience parameters."""
1618
+ # Extract convenience parameters from the incoming value
1619
+ if "display" in value and value["display"] == "grid":
1620
+ self._grid = True
1621
+ if "columns" in value:
1622
+ self._grid_columns = value["columns"]
1623
+ else:
1624
+ self._grid = False
1625
+
1626
+ if "collapsed" in value:
1627
+ self._collapsed = value["collapsed"]
1628
+
1629
+ if "child_prefix" in value:
1630
+ self._child_prefix = value["child_prefix"]
1631
+
1632
+ # Set the base ui_options (excluding convenience parameters)
1633
+ base_ui_options = {
1634
+ k: v for k, v in value.items() if k not in ["display", "columns", "collapsed", "child_prefix"]
1635
+ }
1636
+ self._ui_options = base_ui_options
1637
+
1638
+ def to_dict(self) -> dict[str, Any]:
1639
+ """Override to_dict to use the merged ui_options."""
1640
+ data = super().to_dict()
1641
+ data["ui_options"] = self.ui_options
1642
+ return data
1643
+
1514
1644
  def _custom_getter_for_property_type(self) -> str:
1515
1645
  base_type = super()._custom_getter_for_property_type()
1516
1646
  result = f"list[{base_type}]"
@@ -5,8 +5,9 @@ import logging
5
5
  from abc import ABC, abstractmethod
6
6
  from collections.abc import Callable, Generator, Iterable
7
7
  from concurrent.futures import ThreadPoolExecutor
8
+ from dataclasses import dataclass, field
8
9
  from enum import StrEnum, auto
9
- from typing import TYPE_CHECKING, Any, TypeVar
10
+ from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
10
11
 
11
12
  from griptape_nodes.exe_types.core_types import (
12
13
  BaseNodeElement,
@@ -43,6 +44,7 @@ from griptape_nodes.traits.options import Options
43
44
 
44
45
  if TYPE_CHECKING:
45
46
  from griptape_nodes.exe_types.core_types import NodeMessagePayload
47
+ from griptape_nodes.node_library.library_registry import LibraryNameAndVersion
46
48
 
47
49
  logger = logging.getLogger("griptape_nodes")
48
50
 
@@ -50,6 +52,53 @@ T = TypeVar("T")
50
52
 
51
53
  AsyncResult = Generator[Callable[[], T], T]
52
54
 
55
+ LOCAL_EXECUTION = "Local Execution"
56
+
57
+
58
+ class ImportDependency(NamedTuple):
59
+ """Import dependency specification for a node.
60
+
61
+ Attributes:
62
+ module: The module name to import
63
+ class_name: Optional class name to import from the module. If None, imports the entire module.
64
+ """
65
+
66
+ module: str
67
+ class_name: str | None = None
68
+
69
+
70
+ @dataclass
71
+ class NodeDependencies:
72
+ """Dependencies that a node has on external resources.
73
+
74
+ This class provides a way for nodes to declare their dependencies on workflows,
75
+ static files, Python imports, and libraries. This information can be used by the system
76
+ for workflow packaging, dependency resolution, and deployment planning.
77
+
78
+ Attributes:
79
+ referenced_workflows: Set of workflow names that this node references
80
+ static_files: Set of static file names that this node depends on
81
+ imports: Set of Python imports that this node requires
82
+ libraries: Set of library names and versions that this node uses
83
+ """
84
+
85
+ referenced_workflows: set[str] = field(default_factory=set)
86
+ static_files: set[str] = field(default_factory=set)
87
+ imports: set[ImportDependency] = field(default_factory=set)
88
+ libraries: set[LibraryNameAndVersion] = field(default_factory=set)
89
+
90
+ def aggregate_from(self, other: NodeDependencies) -> None:
91
+ """Aggregate dependencies from another NodeDependencies object into this one.
92
+
93
+ Args:
94
+ other: The NodeDependencies object to aggregate from
95
+ """
96
+ # Aggregate all dependency types - no None checks needed since we use default_factory=set
97
+ self.referenced_workflows.update(other.referenced_workflows)
98
+ self.static_files.update(other.static_files)
99
+ self.imports.update(other.imports)
100
+ self.libraries.update(other.libraries)
101
+
53
102
 
54
103
  class NodeResolutionState(StrEnum):
55
104
  """Possible states for a node during resolution."""
@@ -59,6 +108,23 @@ class NodeResolutionState(StrEnum):
59
108
  RESOLVED = auto()
60
109
 
61
110
 
111
+ def get_library_names_with_publish_handlers() -> list[str]:
112
+ """Get names of all registered libraries that have PublishWorkflowRequest handlers."""
113
+ from griptape_nodes.retained_mode.events.workflow_events import PublishWorkflowRequest
114
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
115
+
116
+ library_manager = GriptapeNodes.LibraryManager()
117
+ event_handlers = library_manager.get_registered_event_handlers(PublishWorkflowRequest)
118
+
119
+ # Always include "local" as the first option
120
+ library_names = [LOCAL_EXECUTION]
121
+
122
+ # Add all registered library names that can handle PublishWorkflowRequest
123
+ library_names.extend(sorted(event_handlers.keys()))
124
+
125
+ return library_names
126
+
127
+
62
128
  class BaseNode(ABC):
63
129
  # Owned by a flow
64
130
  name: str
@@ -104,6 +170,16 @@ class BaseNode(ABC):
104
170
  self.process_generator = None
105
171
  self._tracked_parameters = []
106
172
  self.set_entry_control_parameter(None)
173
+ self.execution_environment = Parameter(
174
+ name="execution_environment",
175
+ tooltip="Environment that the node should execute in",
176
+ type=ParameterTypeBuiltin.STR,
177
+ allowed_modes={ParameterMode.PROPERTY},
178
+ default_value=LOCAL_EXECUTION,
179
+ traits={Options(choices=get_library_names_with_publish_handlers())},
180
+ ui_options={"hide": True},
181
+ )
182
+ self.add_parameter(self.execution_environment)
107
183
 
108
184
  # 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!
109
185
  # https://github.com/griptape-ai/griptape-nodes/issues/994
@@ -384,9 +460,7 @@ class BaseNode(ABC):
384
460
  for name in names:
385
461
  parameter = self.get_parameter_by_name(name)
386
462
  if parameter is not None:
387
- ui_options = parameter.ui_options
388
- ui_options["hide"] = not visible
389
- parameter.ui_options = ui_options
463
+ parameter.ui_options = {**parameter.ui_options, "hide": not visible}
390
464
 
391
465
  def get_message_by_name_or_element_id(self, element: str) -> ParameterMessage | None:
392
466
  element_items = self.root_ui_element.find_elements_by_type(ParameterMessage)
@@ -408,9 +482,7 @@ class BaseNode(ABC):
408
482
  for name in names:
409
483
  message = self.get_message_by_name_or_element_id(name)
410
484
  if message is not None:
411
- ui_options = message.ui_options
412
- ui_options["hide"] = not visible
413
- message.ui_options = ui_options
485
+ message.ui_options = {**message.ui_options, "hide": not visible}
414
486
 
415
487
  def hide_message_by_name(self, names: str | list[str]) -> None:
416
488
  self._set_message_visibility(names, visible=False)
@@ -727,11 +799,9 @@ class BaseNode(ABC):
727
799
  return param
728
800
  return None
729
801
 
730
- # Abstract method to process the node. Must be defined by the type
731
802
  # Must save the values of the output parameters in NodeContext.
732
- @abstractmethod
733
- def process[T](self) -> AsyncResult | None:
734
- pass
803
+ def process(self) -> AsyncResult | None:
804
+ raise NotImplementedError
735
805
 
736
806
  async def aprocess(self) -> None:
737
807
  """Async version of process().
@@ -825,6 +895,35 @@ class BaseNode(ABC):
825
895
  # Then clear the reference to the first spotlight parameter
826
896
  self.current_spotlight_parameter = None
827
897
 
898
+ def get_node_dependencies(self) -> NodeDependencies | None:
899
+ """Return the dependencies that this node has on external resources.
900
+
901
+ This method should be overridden by nodes that have dependencies on:
902
+ - Referenced workflows: Other workflows that this node calls or references
903
+ - Static files: Files that this node reads from or requires for operation
904
+ - Python imports: Modules or classes that this node imports beyond standard dependencies
905
+
906
+ This information can be used by the system for workflow packaging, dependency
907
+ resolution, deployment planning, and ensuring all required resources are available.
908
+
909
+ Returns:
910
+ NodeDependencies object containing the node's dependencies, or None if the node
911
+ has no external dependencies beyond the standard framework dependencies.
912
+
913
+ Example:
914
+ def get_node_dependencies(self) -> NodeDependencies | None:
915
+ return NodeDependencies(
916
+ referenced_workflows={"image_processing_workflow", "validation_workflow"},
917
+ static_files={"config.json", "model_weights.pkl"},
918
+ imports={
919
+ ImportDependency("numpy"),
920
+ ImportDependency("sklearn.linear_model", "LinearRegression"),
921
+ ImportDependency("custom_module", "SpecialProcessor")
922
+ }
923
+ )
924
+ """
925
+ return None
926
+
828
927
  def append_value_to_parameter(self, parameter_name: str, value: Any) -> None:
829
928
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
830
929
 
@@ -867,7 +966,7 @@ class BaseNode(ABC):
867
966
  msg = f"Parameter '{parameter_name} doesn't exist on {self.name}'"
868
967
  raise RuntimeError(msg)
869
968
 
870
- def reorder_elements(self, element_order: list[str | int]) -> None:
969
+ def reorder_elements(self, element_order: list[str] | list[int] | list[str | int]) -> None:
871
970
  """Reorder the elements of this node.
872
971
 
873
972
  Args:
@@ -1109,7 +1208,10 @@ class TrackedParameterOutputValues(dict[str, Any]):
1109
1208
  keys_to_clear = list(self.keys())
1110
1209
  super().clear()
1111
1210
  for key in keys_to_clear:
1112
- self._emit_parameter_change_event(key, None, deleted=True)
1211
+ # Some nodes still have values set, even if their output values are cleared
1212
+ # Here, we are emitting an event with those set values, to not misrepresent the values of the parameters in the UI.
1213
+ value = self._node.get_parameter_value(key)
1214
+ self._emit_parameter_change_event(key, value, deleted=True)
1113
1215
 
1114
1216
  def silent_clear(self) -> None:
1115
1217
  """Clear all values without emitting parameter change events."""
@@ -1393,6 +1495,16 @@ class EndNode(BaseNode):
1393
1495
 
1394
1496
  self.status_component.set_execution_result(was_successful=was_successful, result_details=details)
1395
1497
 
1498
+ # Update all values to use the output value
1499
+ for param in self.parameters:
1500
+ if param.type != ParameterTypeBuiltin.CONTROL_TYPE:
1501
+ value = self.get_parameter_value(param.name)
1502
+ self.parameter_output_values[param.name] = value
1503
+ next_control_output = self.get_next_control_output()
1504
+ # 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
1507
+
1396
1508
 
1397
1509
  class StartLoopNode(BaseNode):
1398
1510
  end_node: EndLoopNode | None = None
@@ -15,6 +15,7 @@ from griptape_nodes.retained_mode.events.base_events import ExecutionEvent, Exec
15
15
  from griptape_nodes.retained_mode.events.execution_events import (
16
16
  ControlFlowResolvedEvent,
17
17
  CurrentControlNodeEvent,
18
+ InvolvedNodesEvent,
18
19
  SelectedControlOutputEvent,
19
20
  )
20
21
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
@@ -252,12 +253,21 @@ class ControlFlowMachine(FSM[ControlFlowContext]):
252
253
  current_nodes = await self._process_nodes_for_dag(start_node)
253
254
  else:
254
255
  current_nodes = [start_node]
256
+ # For control flow/sequential: emit all nodes in flow as involved
255
257
  self._context.current_nodes = current_nodes
256
258
  # Set entry control parameter for initial node (None for workflow start)
257
259
  for node in current_nodes:
258
260
  node.set_entry_control_parameter(None)
259
261
  # Set up to debug
260
262
  self._context.paused = debug_mode
263
+ flow_manager = GriptapeNodes.FlowManager()
264
+ flow = flow_manager.get_flow_by_name(self._context.flow_name)
265
+ involved_nodes = list(flow.nodes.keys())
266
+ GriptapeNodes.EventManager().put_event(
267
+ ExecutionGriptapeNodeEvent(
268
+ wrapped_event=ExecutionEvent(payload=InvolvedNodesEvent(involved_nodes=involved_nodes))
269
+ )
270
+ )
261
271
  await self.start(ResolveNodeState) # Begins the flow
262
272
 
263
273
  async def update(self) -> None:
@@ -43,10 +43,12 @@ class DagBuilder:
43
43
 
44
44
  graphs: dict[str, DirectedGraph] # Str is the name of the start node associated here.
45
45
  node_to_reference: dict[str, DagNode]
46
+ graph_to_nodes: dict[str, set[str]] # Track which nodes belong to which graph
46
47
 
47
48
  def __init__(self) -> None:
48
49
  self.graphs = {}
49
50
  self.node_to_reference: dict[str, DagNode] = {}
51
+ self.graph_to_nodes = {}
50
52
 
51
53
  # Complex with the inner recursive method, but it needs connections and added_nodes.
52
54
  def add_node_with_dependencies(self, node: BaseNode, graph_name: str = "default") -> list[BaseNode]: # noqa: C901
@@ -59,16 +61,15 @@ class DagBuilder:
59
61
  if graph is None:
60
62
  graph = DirectedGraph()
61
63
  self.graphs[graph_name] = graph
64
+ self.graph_to_nodes[graph_name] = set()
62
65
 
63
66
  def _add_node_recursive(current_node: BaseNode, visited: set[str], graph: DirectedGraph) -> None:
64
67
  if current_node.name in visited:
65
68
  return
66
69
  visited.add(current_node.name)
67
-
68
70
  # Skip if already in DAG (use DAG membership, not resolved state)
69
71
  if current_node.name in self.node_to_reference:
70
72
  return
71
-
72
73
  # Process dependencies first (depth-first)
73
74
  ignore_data_dependencies = False
74
75
  # This is specifically for output_selector. Overriding 'initialize_spotlight' doesn't work anymore.
@@ -98,6 +99,10 @@ class DagBuilder:
98
99
  dag_node = DagNode(node_reference=current_node, node_state=NodeState.WAITING)
99
100
  self.node_to_reference[current_node.name] = dag_node
100
101
  graph.add_node(node_for_adding=current_node.name)
102
+
103
+ # Track which nodes belong to this graph
104
+ self.graph_to_nodes[graph_name].add(current_node.name)
105
+
101
106
  # DON'T mark as resolved - that happens during actual execution
102
107
  added_nodes.append(current_node)
103
108
 
@@ -117,12 +122,19 @@ class DagBuilder:
117
122
  graph = DirectedGraph()
118
123
  self.graphs[graph_name] = graph
119
124
  graph.add_node(node_for_adding=node.name)
125
+
126
+ # Track which nodes belong to this graph
127
+ if graph_name not in self.graph_to_nodes:
128
+ self.graph_to_nodes[graph_name] = set()
129
+ self.graph_to_nodes[graph_name].add(node.name)
130
+
120
131
  return dag_node
121
132
 
122
133
  def clear(self) -> None:
123
134
  """Clear all nodes and references from the DAG builder."""
124
135
  self.graphs.clear()
125
136
  self.node_to_reference.clear()
137
+ self.graph_to_nodes.clear()
126
138
 
127
139
  def can_queue_control_node(self, node: DagNode) -> bool:
128
140
  if len(self.graphs) == 1:
@@ -205,3 +217,10 @@ class DagBuilder:
205
217
  return True
206
218
 
207
219
  return False
220
+
221
+ def cleanup_empty_graph_nodes(self, graph_name: str) -> None:
222
+ """Remove nodes from node_to_reference when their graph becomes empty (only in single node resolution)."""
223
+ if graph_name in self.graph_to_nodes:
224
+ for node_name in self.graph_to_nodes[graph_name]:
225
+ self.node_to_reference.pop(node_name, None)
226
+ self.graph_to_nodes.pop(graph_name, None)