griptape-nodes 0.52.1__py3-none-any.whl → 0.53.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 (46) hide show
  1. griptape_nodes/__init__.py +6 -943
  2. griptape_nodes/__main__.py +6 -0
  3. griptape_nodes/app/app.py +45 -61
  4. griptape_nodes/cli/__init__.py +1 -0
  5. griptape_nodes/cli/commands/__init__.py +1 -0
  6. griptape_nodes/cli/commands/config.py +71 -0
  7. griptape_nodes/cli/commands/engine.py +80 -0
  8. griptape_nodes/cli/commands/init.py +548 -0
  9. griptape_nodes/cli/commands/libraries.py +90 -0
  10. griptape_nodes/cli/commands/self.py +117 -0
  11. griptape_nodes/cli/main.py +46 -0
  12. griptape_nodes/cli/shared.py +84 -0
  13. griptape_nodes/common/__init__.py +1 -0
  14. griptape_nodes/common/directed_graph.py +55 -0
  15. griptape_nodes/drivers/storage/local_storage_driver.py +7 -2
  16. griptape_nodes/exe_types/core_types.py +60 -2
  17. griptape_nodes/exe_types/node_types.py +38 -24
  18. griptape_nodes/machines/control_flow.py +86 -22
  19. griptape_nodes/machines/fsm.py +10 -1
  20. griptape_nodes/machines/parallel_resolution.py +570 -0
  21. griptape_nodes/machines/{node_resolution.py → sequential_resolution.py} +22 -51
  22. griptape_nodes/retained_mode/events/base_events.py +2 -2
  23. griptape_nodes/retained_mode/events/node_events.py +4 -3
  24. griptape_nodes/retained_mode/griptape_nodes.py +25 -12
  25. griptape_nodes/retained_mode/managers/agent_manager.py +9 -5
  26. griptape_nodes/retained_mode/managers/arbitrary_code_exec_manager.py +3 -1
  27. griptape_nodes/retained_mode/managers/context_manager.py +6 -5
  28. griptape_nodes/retained_mode/managers/flow_manager.py +117 -204
  29. griptape_nodes/retained_mode/managers/library_lifecycle/library_directory.py +1 -1
  30. griptape_nodes/retained_mode/managers/library_manager.py +35 -25
  31. griptape_nodes/retained_mode/managers/node_manager.py +81 -199
  32. griptape_nodes/retained_mode/managers/object_manager.py +11 -5
  33. griptape_nodes/retained_mode/managers/os_manager.py +24 -9
  34. griptape_nodes/retained_mode/managers/secrets_manager.py +8 -4
  35. griptape_nodes/retained_mode/managers/settings.py +32 -1
  36. griptape_nodes/retained_mode/managers/static_files_manager.py +8 -3
  37. griptape_nodes/retained_mode/managers/sync_manager.py +8 -5
  38. griptape_nodes/retained_mode/managers/workflow_manager.py +110 -122
  39. griptape_nodes/traits/add_param_button.py +1 -1
  40. griptape_nodes/traits/button.py +216 -6
  41. griptape_nodes/traits/color_picker.py +66 -0
  42. griptape_nodes/traits/traits.json +4 -0
  43. {griptape_nodes-0.52.1.dist-info → griptape_nodes-0.53.0.dist-info}/METADATA +2 -1
  44. {griptape_nodes-0.52.1.dist-info → griptape_nodes-0.53.0.dist-info}/RECORD +46 -32
  45. {griptape_nodes-0.52.1.dist-info → griptape_nodes-0.53.0.dist-info}/WHEEL +0 -0
  46. {griptape_nodes-0.52.1.dist-info → griptape_nodes-0.53.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,117 @@
1
+ """Self command for Griptape Nodes CLI."""
2
+
3
+ import shutil
4
+ import sys
5
+
6
+ import typer
7
+
8
+ from griptape_nodes.cli.shared import (
9
+ CONFIG_DIR,
10
+ DATA_DIR,
11
+ GITHUB_UPDATE_URL,
12
+ LATEST_TAG,
13
+ PYPI_UPDATE_URL,
14
+ config_manager,
15
+ console,
16
+ os_manager,
17
+ )
18
+ from griptape_nodes.utils.uv_utils import find_uv_bin
19
+ from griptape_nodes.utils.version_utils import (
20
+ get_complete_version_string,
21
+ get_current_version,
22
+ get_latest_version_git,
23
+ get_latest_version_pypi,
24
+ )
25
+
26
+ app = typer.Typer(help="Manage this CLI installation.")
27
+
28
+
29
+ @app.command()
30
+ def update() -> None:
31
+ """Update the CLI."""
32
+ _update_self()
33
+
34
+
35
+ @app.command()
36
+ def uninstall() -> None:
37
+ """Uninstall the CLI."""
38
+ _uninstall_self()
39
+
40
+
41
+ @app.command()
42
+ def version() -> None:
43
+ """Print the CLI version."""
44
+ _print_current_version()
45
+
46
+
47
+ def _get_latest_version(package: str, install_source: str) -> str:
48
+ """Fetches the latest release tag from PyPI.
49
+
50
+ Args:
51
+ package: The name of the package to fetch the latest version for.
52
+ install_source: The source from which the package is installed (e.g., "pypi", "git", "file").
53
+
54
+ Returns:
55
+ str: Latest release tag (e.g., "v0.31.4")
56
+ """
57
+ if install_source == "pypi":
58
+ return get_latest_version_pypi(package, PYPI_UPDATE_URL)
59
+ if install_source == "git":
60
+ return get_latest_version_git(package, GITHUB_UPDATE_URL, LATEST_TAG)
61
+ # If the package is installed from a file, just return the current version since the user is likely managing it manually
62
+ return get_current_version()
63
+
64
+
65
+ def _update_self() -> None:
66
+ """Installs the latest release of the CLI *and* refreshes bundled libraries."""
67
+ console.print("[bold green]Starting updater...[/bold green]")
68
+
69
+ os_manager.replace_process([sys.executable, "-m", "griptape_nodes.updater"])
70
+
71
+
72
+ def _print_current_version() -> None:
73
+ """Prints the current version of the script."""
74
+ version_string = get_complete_version_string()
75
+ console.print(f"[bold green]{version_string}[/bold green]")
76
+
77
+
78
+ def _uninstall_self() -> None:
79
+ """Uninstalls itself by removing config/data directories and the executable."""
80
+ console.print("[bold]Uninstalling Griptape Nodes...[/bold]")
81
+
82
+ # Remove config and data directories
83
+ console.print("[bold]Removing config and data directories...[/bold]")
84
+ dirs = [(CONFIG_DIR, "Config Dir"), (DATA_DIR, "Data Dir")]
85
+ caveats = []
86
+ for dir_path, dir_name in dirs:
87
+ if dir_path.exists():
88
+ console.print(f"[bold]Removing {dir_name} '{dir_path}'...[/bold]")
89
+ try:
90
+ shutil.rmtree(dir_path)
91
+ except OSError as exc:
92
+ console.print(f"[red]Error removing {dir_name} '{dir_path}': {exc}[/red]")
93
+ caveats.append(
94
+ f"- [red]Error removing {dir_name} '{dir_path}'. You may want remove this directory manually.[/red]"
95
+ )
96
+ else:
97
+ console.print(f"[yellow]{dir_name} '{dir_path}' does not exist; skipping.[/yellow]")
98
+
99
+ # Handle any remaining config files not removed by design
100
+ remaining_config_files = config_manager.config_files
101
+ if remaining_config_files:
102
+ caveats.append("- Some config files were intentionally not removed:")
103
+ caveats.extend(f"\t[yellow]- {file}[/yellow]" for file in remaining_config_files)
104
+
105
+ # If there were any caveats to the uninstallation process, print them
106
+ if caveats:
107
+ console.print("[bold]Caveats:[/bold]")
108
+ for line in caveats:
109
+ console.print(line)
110
+
111
+ # Remove the executable
112
+ console.print("[bold]Removing the executable...[/bold]")
113
+ console.print("[bold yellow]When done, press Enter to exit.[/bold yellow]")
114
+
115
+ # Remove the tool using UV
116
+ uv_path = find_uv_bin()
117
+ os_manager.replace_process([uv_path, "tool", "uninstall", "griptape-nodes"])
@@ -0,0 +1,46 @@
1
+ """Main CLI application using typer."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ # Add current directory to path for imports to work
10
+ sys.path.append(str(Path.cwd()))
11
+
12
+ from griptape_nodes.cli.commands import config, engine, init, libraries, self
13
+
14
+ console = Console()
15
+
16
+ app = typer.Typer(
17
+ name="griptape-nodes",
18
+ help="Griptape Nodes Engine CLI",
19
+ no_args_is_help=False,
20
+ rich_markup_mode="rich",
21
+ invoke_without_command=True,
22
+ )
23
+
24
+ # Add subcommands
25
+ app.command("init", help="Initialize engine configuration.")(init.init_command)
26
+ app.add_typer(config.app, name="config")
27
+ app.add_typer(self.app, name="self")
28
+ app.add_typer(libraries.app, name="libraries")
29
+ app.command("engine")(engine.engine_command)
30
+
31
+
32
+ @app.callback()
33
+ def main(
34
+ ctx: typer.Context,
35
+ no_update: bool = typer.Option( # noqa: FBT001
36
+ False, "--no-update", help="Skip the auto-update check."
37
+ ),
38
+ ) -> None:
39
+ """Griptape Nodes Engine CLI."""
40
+ if ctx.invoked_subcommand is None:
41
+ # Default to engine command when no subcommand is specified
42
+ engine.engine_command(no_update=no_update)
43
+
44
+
45
+ if __name__ == "__main__":
46
+ app()
@@ -0,0 +1,84 @@
1
+ """Shared constants and managers for CLI commands."""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from rich.console import Console
9
+ from xdg_base_dirs import xdg_config_home, xdg_data_home
10
+
11
+ from griptape_nodes.retained_mode.managers.config_manager import ConfigManager
12
+ from griptape_nodes.retained_mode.managers.os_manager import OSManager
13
+ from griptape_nodes.retained_mode.managers.secrets_manager import SecretsManager
14
+
15
+
16
+ @dataclass
17
+ class InitConfig:
18
+ """Configuration for initialization."""
19
+
20
+ interactive: bool = True
21
+ workspace_directory: str | None = None
22
+ api_key: str | None = None
23
+ storage_backend: str | None = None
24
+ register_advanced_library: bool | None = None
25
+ config_values: dict[str, Any] | None = None
26
+ secret_values: dict[str, str] | None = None
27
+ libraries_sync: bool | None = None
28
+ bucket_name: str | None = None
29
+
30
+
31
+ # Initialize console
32
+ console = Console()
33
+
34
+ # Directory paths
35
+ CONFIG_DIR = xdg_config_home() / "griptape_nodes"
36
+ DATA_DIR = xdg_data_home() / "griptape_nodes"
37
+ ENV_FILE = CONFIG_DIR / ".env"
38
+ CONFIG_FILE = CONFIG_DIR / "griptape_nodes_config.json"
39
+
40
+ # URLs and constants
41
+ LATEST_TAG = "latest"
42
+ PACKAGE_NAME = "griptape-nodes"
43
+ NODES_APP_URL = "https://nodes.griptape.ai"
44
+ NODES_TARBALL_URL = "https://github.com/griptape-ai/griptape-nodes/archive/refs/tags/{tag}.tar.gz"
45
+ PYPI_UPDATE_URL = "https://pypi.org/pypi/{package}/json"
46
+ GITHUB_UPDATE_URL = "https://api.github.com/repos/griptape-ai/{package}/git/refs/tags/{revision}"
47
+ GT_CLOUD_BASE_URL = os.getenv("GT_CLOUD_BASE_URL", "https://cloud.griptape.ai")
48
+
49
+ # Environment variable defaults for init configuration
50
+ ENV_WORKSPACE_DIRECTORY = os.getenv("GTN_WORKSPACE_DIRECTORY")
51
+ ENV_API_KEY = os.getenv("GTN_API_KEY")
52
+ ENV_STORAGE_BACKEND = os.getenv("GTN_STORAGE_BACKEND")
53
+ ENV_REGISTER_ADVANCED_LIBRARY = (
54
+ os.getenv("GTN_REGISTER_ADVANCED_LIBRARY", "false").lower() == "true"
55
+ if os.getenv("GTN_REGISTER_ADVANCED_LIBRARY") is not None
56
+ else None
57
+ )
58
+ ENV_LIBRARIES_SYNC = (
59
+ os.getenv("GTN_LIBRARIES_SYNC", "false").lower() == "true" if os.getenv("GTN_LIBRARIES_SYNC") is not None else None
60
+ )
61
+ ENV_GTN_BUCKET_NAME = os.getenv("GTN_BUCKET_NAME")
62
+ ENV_LIBRARIES_BASE_DIR = os.getenv("GTN_LIBRARIES_BASE_DIR", str(DATA_DIR / "libraries"))
63
+
64
+ # Initialize managers
65
+ config_manager = ConfigManager()
66
+ secrets_manager = SecretsManager(config_manager)
67
+ os_manager = OSManager()
68
+
69
+
70
+ def init_system_config() -> None:
71
+ """Initializes the system config directory if it doesn't exist."""
72
+ if not CONFIG_DIR.exists():
73
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
74
+
75
+ files_to_create = [
76
+ (ENV_FILE, ""),
77
+ (CONFIG_FILE, "{}"),
78
+ ]
79
+
80
+ for file_name in files_to_create:
81
+ file_path = CONFIG_DIR / file_name[0]
82
+ if not file_path.exists():
83
+ with Path.open(file_path, "w", encoding="utf-8") as file:
84
+ file.write(file_name[1])
@@ -0,0 +1 @@
1
+ """Common package."""
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ logger = logging.getLogger("griptape_nodes")
6
+
7
+
8
+ class DirectedGraph:
9
+ """Directed graph implementation using Python's graphlib for DAG operations."""
10
+
11
+ def __init__(self) -> None:
12
+ self._nodes: set[str] = set()
13
+ self._predecessors: dict[str, set[str]] = {}
14
+
15
+ def add_node(self, node_for_adding: str) -> None:
16
+ """Add a node to the graph."""
17
+ self._nodes.add(node_for_adding)
18
+ if node_for_adding not in self._predecessors:
19
+ self._predecessors[node_for_adding] = set()
20
+
21
+ def add_edge(self, from_node: str, to_node: str) -> None:
22
+ """Add a directed edge from from_node to to_node."""
23
+ self.add_node(from_node)
24
+ self.add_node(to_node)
25
+ self._predecessors[to_node].add(from_node)
26
+
27
+ def nodes(self) -> set[str]:
28
+ """Return all nodes in the graph."""
29
+ return self._nodes.copy()
30
+
31
+ def in_degree(self, node: str) -> int:
32
+ """Return the in-degree of a node (number of incoming edges)."""
33
+ if node not in self._nodes:
34
+ return 0
35
+ return len(self._predecessors.get(node, set()))
36
+
37
+ def remove_node(self, node: str) -> None:
38
+ """Remove a node and all its edges from the graph."""
39
+ if node not in self._nodes:
40
+ return
41
+
42
+ self._nodes.remove(node)
43
+
44
+ # Remove this node from all predecessor lists
45
+ for predecessors in self._predecessors.values():
46
+ predecessors.discard(node)
47
+
48
+ # Remove this node's predecessor entry
49
+ if node in self._predecessors:
50
+ del self._predecessors[node]
51
+
52
+ def clear(self) -> None:
53
+ """Clear all nodes and edges from the graph."""
54
+ self._nodes.clear()
55
+ self._predecessors.clear()
@@ -4,8 +4,6 @@ from urllib.parse import urljoin
4
4
 
5
5
  import httpx
6
6
 
7
- from griptape_nodes.app.api import STATIC_SERVER_HOST, STATIC_SERVER_PORT, STATIC_SERVER_URL
8
- from griptape_nodes.app.app import STATIC_SERVER_ENABLED
9
7
  from griptape_nodes.drivers.storage.base_storage_driver import BaseStorageDriver, CreateSignedUploadUrlResponse
10
8
 
11
9
  logger = logging.getLogger("griptape_nodes")
@@ -20,6 +18,13 @@ class LocalStorageDriver(BaseStorageDriver):
20
18
  Args:
21
19
  base_url: The base URL for the static file server. If not provided, it will be constructed
22
20
  """
21
+ from griptape_nodes.app.api import (
22
+ STATIC_SERVER_ENABLED,
23
+ STATIC_SERVER_HOST,
24
+ STATIC_SERVER_PORT,
25
+ STATIC_SERVER_URL,
26
+ )
27
+
23
28
  if not STATIC_SERVER_ENABLED:
24
29
  msg = "Static server is not enabled. Please set STATIC_SERVER_ENABLED to True."
25
30
  raise ValueError(msg)
@@ -4,15 +4,48 @@ import uuid
4
4
  from abc import ABC, abstractmethod
5
5
  from copy import deepcopy
6
6
  from dataclasses import dataclass, field
7
- from enum import Enum, auto
7
+ from enum import Enum, StrEnum, auto
8
8
  from typing import TYPE_CHECKING, Any, ClassVar, Literal, NamedTuple, Self, TypeVar
9
9
 
10
+ from pydantic import BaseModel
11
+
12
+
13
+ class NodeMessagePayload(BaseModel):
14
+ """Structured payload for node messages.
15
+
16
+ This replaces the use of Any in message payloads, providing
17
+ better type safety and validation for node message handling.
18
+ """
19
+
20
+ data: Any = None
21
+
22
+
23
+ class NodeMessageResult(BaseModel):
24
+ """Result from a node message callback.
25
+
26
+ Attributes:
27
+ success: True if the message was handled successfully, False otherwise
28
+ details: Human-readable description of what happened
29
+ response: Optional response data to return to the sender
30
+ altered_workflow_state: True if the message handling altered workflow state.
31
+ Clients can use this to determine if the workflow needs to be re-saved.
32
+ """
33
+
34
+ success: bool
35
+ details: str
36
+ response: NodeMessagePayload | None = None
37
+ altered_workflow_state: bool = True
38
+
39
+
10
40
  if TYPE_CHECKING:
11
41
  from collections.abc import Callable
12
42
  from types import TracebackType
13
43
 
14
44
  from griptape_nodes.exe_types.node_types import BaseNode
15
45
 
46
+ # Type alias for element message callback functions
47
+ type ElementMessageCallback = Callable[[str, "NodeMessagePayload | None"], "NodeMessageResult"]
48
+
16
49
  T = TypeVar("T", bound="Parameter")
17
50
  N = TypeVar("N", bound="BaseNodeElement")
18
51
 
@@ -24,7 +57,7 @@ class ParameterMode(Enum):
24
57
  PROPERTY = auto()
25
58
 
26
59
 
27
- class ParameterTypeBuiltin(Enum):
60
+ class ParameterTypeBuiltin(StrEnum):
28
61
  STR = "str"
29
62
  BOOL = "bool"
30
63
  INT = "int"
@@ -416,6 +449,31 @@ class BaseNodeElement:
416
449
  }
417
450
  return event_data
418
451
 
452
+ def on_message_received(self, message_type: str, message: NodeMessagePayload | None) -> NodeMessageResult | None:
453
+ """Virtual method for handling messages sent to this element.
454
+
455
+ Attempts to delegate to child elements first. If any child handles the message
456
+ (returns non-None), that result is returned immediately. Otherwise, falls back
457
+ to default behavior (return None).
458
+
459
+ Args:
460
+ message_type: String indicating the message type for parsing
461
+ message: Message payload as NodeMessagePayload or None
462
+
463
+ Returns:
464
+ NodeMessageResult | None: Result if handled, None if no handler available
465
+ """
466
+ # Try to delegate to all children first
467
+ # NOTE: This returns immediately on the first child that accepts the message (returns non-None).
468
+ # In the future, we may need to expand this to handle multiple children processing the same message.
469
+ for child in self._children:
470
+ result = child.on_message_received(message_type, message)
471
+ if result is not None:
472
+ return result
473
+
474
+ # No child handled it, return None (indicating no handler)
475
+ return None
476
+
419
477
 
420
478
  class UIOptionsMixin:
421
479
  """Mixin providing UI options update functionality for classes with ui_options."""
@@ -6,12 +6,13 @@ from abc import ABC, abstractmethod
6
6
  from collections.abc import Callable, Generator, Iterable
7
7
  from concurrent.futures import ThreadPoolExecutor
8
8
  from enum import StrEnum, auto
9
- from typing import Any, NamedTuple, TypeVar
9
+ from typing import TYPE_CHECKING, Any, TypeVar
10
10
 
11
11
  from griptape_nodes.exe_types.core_types import (
12
12
  BaseNodeElement,
13
13
  ControlParameterInput,
14
14
  ControlParameterOutput,
15
+ NodeMessageResult,
15
16
  Parameter,
16
17
  ParameterContainer,
17
18
  ParameterDictionary,
@@ -39,6 +40,9 @@ from griptape_nodes.retained_mode.events.parameter_events import (
39
40
  )
40
41
  from griptape_nodes.traits.options import Options
41
42
 
43
+ if TYPE_CHECKING:
44
+ from griptape_nodes.exe_types.core_types import NodeMessagePayload
45
+
42
46
  logger = logging.getLogger("griptape_nodes")
43
47
 
44
48
  T = TypeVar("T")
@@ -54,23 +58,6 @@ class NodeResolutionState(StrEnum):
54
58
  RESOLVED = auto()
55
59
 
56
60
 
57
- class NodeMessageResult(NamedTuple):
58
- """Result from a node message callback.
59
-
60
- Attributes:
61
- success: True if the message was handled successfully, False otherwise
62
- details: Human-readable description of what happened
63
- response: Optional response data to return to the sender
64
- altered_workflow_state: True if the message handling altered workflow state.
65
- Clients can use this to determine if the workflow needs to be re-saved.
66
- """
67
-
68
- success: bool
69
- details: str
70
- response: Any = None
71
- altered_workflow_state: bool = True
72
-
73
-
74
61
  class BaseNode(ABC):
75
62
  # Owned by a flow
76
63
  name: str
@@ -80,7 +67,7 @@ class BaseNode(ABC):
80
67
  state: NodeResolutionState
81
68
  current_spotlight_parameter: Parameter | None = None
82
69
  parameter_values: dict[str, Any]
83
- parameter_output_values: dict[str, Any]
70
+ parameter_output_values: TrackedParameterOutputValues
84
71
  stop_flow: bool = False
85
72
  root_ui_element: BaseNodeElement
86
73
  _tracked_parameters: list[BaseNodeElement]
@@ -289,15 +276,18 @@ class BaseNode(ABC):
289
276
 
290
277
  def on_node_message_received(
291
278
  self,
292
- optional_element_name: str | None, # noqa: ARG002
279
+ optional_element_name: str | None,
293
280
  message_type: str,
294
- message: Any, # noqa: ARG002
281
+ message: NodeMessagePayload | None,
295
282
  ) -> NodeMessageResult:
296
283
  """Callback for when a message is sent directly to this node.
297
284
 
298
285
  Custom nodes may elect to override this method to handle specific message types
299
286
  and implement custom communication patterns with external systems.
300
287
 
288
+ If optional_element_name is provided, this method will attempt to find the
289
+ element and delegate the message handling to that element's on_message_received method.
290
+
301
291
  Args:
302
292
  optional_element_name: Optional element name this message relates to
303
293
  message_type: String indicating the message type for parsing
@@ -306,6 +296,26 @@ class BaseNode(ABC):
306
296
  Returns:
307
297
  NodeMessageResult: Result containing success status, details, and optional response
308
298
  """
299
+ # If optional_element_name is provided, delegate to the specific element
300
+ if optional_element_name is not None:
301
+ element = self.root_ui_element.find_element_by_name(optional_element_name)
302
+ if element is None:
303
+ return NodeMessageResult(
304
+ success=False,
305
+ details=f"Node '{self.name}' received message for element '{optional_element_name}' but no element with that name was found",
306
+ response=None,
307
+ )
308
+ # Delegate to the element's message handler
309
+ result = element.on_message_received(message_type, message)
310
+ if result is None:
311
+ return NodeMessageResult(
312
+ success=False,
313
+ details=f"Element '{optional_element_name}' received message type '{message_type}' but no handler was available",
314
+ response=None,
315
+ )
316
+ return result
317
+
318
+ # If no element name specified, fall back to node-level handling
309
319
  return NodeMessageResult(
310
320
  success=False,
311
321
  details=f"Node '{self.name}' was sent a message of type '{message_type}'. Failed because no message handler was specified for this node. Implement the on_node_message_received method in this node class in order for it to receive messages.",
@@ -620,10 +630,10 @@ class BaseNode(ABC):
620
630
  # Allow custom node logic to prepare and possibly mutate the value before it is actually set.
621
631
  # Record any parameters modified for cascading.
622
632
  if not initial_setup:
623
- if not skip_before_value_set:
624
- final_value = self.before_value_set(parameter=parameter, value=candidate_value)
625
- else:
633
+ if skip_before_value_set:
626
634
  final_value = candidate_value
635
+ else:
636
+ final_value = self.before_value_set(parameter=parameter, value=candidate_value)
627
637
  # ACTUALLY SET THE NEW VALUE
628
638
  self.parameter_values[param_name] = final_value
629
639
 
@@ -1097,6 +1107,10 @@ class TrackedParameterOutputValues(dict[str, Any]):
1097
1107
  for key in keys_to_clear:
1098
1108
  self._emit_parameter_change_event(key, None, deleted=True)
1099
1109
 
1110
+ def silent_clear(self) -> None:
1111
+ """Clear all values without emitting parameter change events."""
1112
+ super().clear()
1113
+
1100
1114
  def update(self, *args, **kwargs) -> None:
1101
1115
  # Handle both dict.update(other) and dict.update(**kwargs) patterns
1102
1116
  if args: