griptape-nodes 0.37.1__py3-none-any.whl → 0.38.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 (38) hide show
  1. griptape_nodes/__init__.py +292 -132
  2. griptape_nodes/app/__init__.py +1 -6
  3. griptape_nodes/app/app.py +108 -76
  4. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +80 -5
  5. griptape_nodes/drivers/storage/local_storage_driver.py +5 -1
  6. griptape_nodes/exe_types/core_types.py +84 -3
  7. griptape_nodes/exe_types/node_types.py +260 -50
  8. griptape_nodes/machines/node_resolution.py +2 -14
  9. griptape_nodes/retained_mode/events/agent_events.py +7 -0
  10. griptape_nodes/retained_mode/events/base_events.py +16 -0
  11. griptape_nodes/retained_mode/events/library_events.py +26 -0
  12. griptape_nodes/retained_mode/events/parameter_events.py +31 -0
  13. griptape_nodes/retained_mode/griptape_nodes.py +32 -0
  14. griptape_nodes/retained_mode/managers/agent_manager.py +25 -12
  15. griptape_nodes/retained_mode/managers/config_manager.py +37 -4
  16. griptape_nodes/retained_mode/managers/event_manager.py +15 -0
  17. griptape_nodes/retained_mode/managers/flow_manager.py +64 -61
  18. griptape_nodes/retained_mode/managers/library_manager.py +215 -45
  19. griptape_nodes/retained_mode/managers/node_manager.py +344 -147
  20. griptape_nodes/retained_mode/managers/operation_manager.py +6 -0
  21. griptape_nodes/retained_mode/managers/os_manager.py +6 -1
  22. griptape_nodes/retained_mode/managers/secrets_manager.py +7 -2
  23. griptape_nodes/retained_mode/managers/settings.py +2 -11
  24. griptape_nodes/retained_mode/managers/static_files_manager.py +12 -3
  25. griptape_nodes/retained_mode/managers/version_compatibility_manager.py +105 -0
  26. griptape_nodes/retained_mode/managers/workflow_manager.py +4 -4
  27. griptape_nodes/updater/__init__.py +14 -8
  28. griptape_nodes/version_compatibility/__init__.py +1 -0
  29. griptape_nodes/version_compatibility/versions/__init__.py +1 -0
  30. griptape_nodes/version_compatibility/versions/v0_39_0/__init__.py +1 -0
  31. griptape_nodes/version_compatibility/versions/v0_39_0/modified_parameters_set_removal.py +77 -0
  32. {griptape_nodes-0.37.1.dist-info → griptape_nodes-0.38.0.dist-info}/METADATA +4 -1
  33. {griptape_nodes-0.37.1.dist-info → griptape_nodes-0.38.0.dist-info}/RECORD +36 -33
  34. griptape_nodes/app/app_websocket.py +0 -481
  35. griptape_nodes/app/nodes_api_socket_manager.py +0 -117
  36. {griptape_nodes-0.37.1.dist-info → griptape_nodes-0.38.0.dist-info}/WHEEL +0 -0
  37. {griptape_nodes-0.37.1.dist-info → griptape_nodes-0.38.0.dist-info}/entry_points.txt +0 -0
  38. {griptape_nodes-0.37.1.dist-info → griptape_nodes-0.38.0.dist-info}/licenses/LICENSE +0 -0
griptape_nodes/app/app.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import binascii
4
5
  import json
5
6
  import logging
@@ -9,11 +10,9 @@ import sys
9
10
  import threading
10
11
  from pathlib import Path
11
12
  from queue import Queue
12
- from time import sleep
13
13
  from typing import Any, cast
14
14
  from urllib.parse import urljoin
15
15
 
16
- import httpx
17
16
  import uvicorn
18
17
  from dotenv import get_key
19
18
  from fastapi import FastAPI, HTTPException, Request
@@ -27,10 +26,10 @@ from rich.align import Align
27
26
  from rich.console import Console
28
27
  from rich.logging import RichHandler
29
28
  from rich.panel import Panel
29
+ from websockets.asyncio.client import connect
30
+ from websockets.exceptions import ConnectionClosed, WebSocketException
30
31
  from xdg_base_dirs import xdg_config_home
31
32
 
32
- from griptape_nodes.app.nodes_api_socket_manager import NodesApiSocketManager
33
-
34
33
  # This import is necessary to register all events, even if not technically used
35
34
  from griptape_nodes.retained_mode.events import app_events, execution_events
36
35
  from griptape_nodes.retained_mode.events.base_events import (
@@ -50,6 +49,10 @@ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
50
49
  # This is a global event queue that will be used to pass events between threads
51
50
  event_queue = Queue()
52
51
 
52
+ # Global WebSocket connection for sending events
53
+ ws_connection_for_sending = None
54
+ event_loop = None
55
+
53
56
  # Whether to enable the static server
54
57
  STATIC_SERVER_ENABLED = os.getenv("STATIC_SERVER_ENABLED", "true").lower() == "true"
55
58
  # Host of the static server
@@ -77,7 +80,7 @@ class EventLogHandler(logging.Handler):
77
80
 
78
81
 
79
82
  # Logger for this module. Important that this is not the same as the griptape_nodes logger or else we'll have infinite log events.
80
- logger = logging.getLogger(__name__)
83
+ logger = logging.getLogger("griptape_nodes_app")
81
84
  console = Console()
82
85
 
83
86
 
@@ -86,7 +89,7 @@ def start_app() -> None:
86
89
 
87
90
  Starts the event loop and listens for events from the Nodes API.
88
91
  """
89
- global socket_manager # noqa: PLW0603 # Need to initialize the socket lazily here to avoid auth-ing too early
92
+ _init_event_listeners()
90
93
 
91
94
  griptape_nodes_logger = logging.getLogger("griptape_nodes")
92
95
  # When running as an app, we want to forward all log messages to the event queue so they can be sent to the GUI
@@ -94,11 +97,6 @@ def start_app() -> None:
94
97
  griptape_nodes_logger.addHandler(RichHandler(show_time=True, show_path=False, markup=True, rich_tracebacks=True))
95
98
  griptape_nodes_logger.setLevel(logging.INFO)
96
99
 
97
- # Listen for SSE events from the Nodes API in a separate thread
98
- socket_manager = NodesApiSocketManager()
99
-
100
- _init_event_listeners()
101
-
102
100
  # Listen for any signals to exit the app
103
101
  for sig in (signal.SIGINT, signal.SIGTERM):
104
102
  signal.signal(sig, lambda *_: sys.exit(0))
@@ -220,45 +218,50 @@ def _init_event_listeners() -> None:
220
218
  )
221
219
 
222
220
 
223
- def _listen_for_api_events() -> None:
224
- """Listen for events from the Nodes API and process them."""
225
- init = False
226
- endpoint = urljoin(os.getenv("GRIPTAPE_NODES_API_BASE_URL", "https://api.nodes.griptape.ai"), "/api/engines/stream")
221
+ async def _alisten_for_api_requests() -> None:
222
+ """Listen for events from the Nodes API and process them asynchronously."""
223
+ global ws_connection_for_sending, event_loop # noqa: PLW0603
224
+ event_loop = asyncio.get_running_loop() # Store the event loop reference
227
225
  nodes_app_url = os.getenv("GRIPTAPE_NODES_UI_BASE_URL", "https://nodes.griptape.ai")
228
- logger.info("Listening for events from Nodes API at %s", endpoint)
229
- while True:
226
+ logger.info("Listening for events from Nodes API via async WebSocket")
227
+
228
+ # Auto reconnect https://websockets.readthedocs.io/en/stable/reference/asyncio/client.html#opening-a-connection
229
+ connection_stream = __create_async_websocket_connection()
230
+ initialized = False
231
+ async for ws_connection in connection_stream:
230
232
  try:
231
- with httpx.stream("get", endpoint, auth=__build_authorized_request, timeout=None) as response: # noqa: S113 We intentionally want to never timeout
232
- __check_api_key_validity(response)
233
-
234
- response.raise_for_status()
235
-
236
- for line in response.iter_lines():
237
- if line.startswith("data:"):
238
- data = line.removeprefix("data:").strip()
239
- if data == "START":
240
- if not init:
241
- __broadcast_app_initialization_complete(nodes_app_url)
242
- init = True
243
- else:
244
- try:
245
- event = json.loads(data)
246
- # With heartbeat events, we skip the regular processing and just send the heartbeat
247
- # Technically no longer needed since https://github.com/griptape-ai/griptape-nodes/pull/369
248
- # but we don't have a proper EventRequest for it yet.
249
- if event.get("request_type") == "Heartbeat":
250
- session_id = GriptapeNodes.get_session_id()
251
- socket_manager.heartbeat(session_id=session_id, request=event["request"])
252
- else:
253
- __process_api_event(event)
254
- except Exception:
255
- logger.exception("Error processing event, skipping.")
256
-
257
- except httpx.RemoteProtocolError as e:
258
- logger.debug("Server closed connection, this is expected. Reconnecting... %s", e)
233
+ ws_connection_for_sending = ws_connection # Store for sending events
234
+ if not initialized:
235
+ __broadcast_app_initialization_complete(nodes_app_url)
236
+ initialized = True
237
+
238
+ async for message in ws_connection:
239
+ try:
240
+ data = json.loads(message)
241
+
242
+ payload = data.get("payload", {})
243
+ # With heartbeat events, we skip the regular processing and just send the heartbeat
244
+ # Technically no longer needed since https://github.com/griptape-ai/griptape-nodes/pull/369
245
+ # but we don't have a proper EventRequest for it yet.
246
+ if payload.get("request_type") == "Heartbeat":
247
+ session_id = GriptapeNodes.get_session_id()
248
+ await __send_heartbeat(
249
+ session_id=session_id, request=payload["request"], ws_connection=ws_connection
250
+ )
251
+ else:
252
+ __process_api_event(payload)
253
+ except Exception:
254
+ logger.exception("Error processing event, skipping.")
255
+ except ConnectionClosed:
256
+ continue
259
257
  except Exception as e:
260
258
  logger.error("Error while listening for events. Retrying in 2 seconds... %s", e)
261
- sleep(2)
259
+ await asyncio.sleep(2)
260
+
261
+
262
+ def _listen_for_api_events() -> None:
263
+ """Run the async WebSocket listener in an event loop."""
264
+ asyncio.run(_alisten_for_api_requests())
262
265
 
263
266
 
264
267
  def __process_node_event(event: GriptapeNodeEvent) -> None:
@@ -272,10 +275,9 @@ def __process_node_event(event: GriptapeNodeEvent) -> None:
272
275
  else:
273
276
  msg = f"Unknown/unsupported result event type encountered: '{type(result_event)}'."
274
277
  raise TypeError(msg) from None
275
-
276
278
  # Don't send events over the wire that don't have a request_id set (e.g. engine-internal events)
277
279
  event_json = result_event.json()
278
- socket_manager.emit(dest_socket, event_json)
280
+ __schedule_async_task(__emit_message(dest_socket, event_json))
279
281
 
280
282
 
281
283
  def __process_execution_node_event(event: ExecutionGriptapeNodeEvent) -> None:
@@ -297,8 +299,7 @@ def __process_execution_node_event(event: ExecutionGriptapeNodeEvent) -> None:
297
299
  msg = "Node start and finish do not match."
298
300
  raise KeyError(msg) from None
299
301
  GriptapeNodes.EventManager().current_active_node = None
300
- # Set the node name here so I am not double importing
301
- socket_manager.emit("execution_event", event_json)
302
+ __schedule_async_task(__emit_message("execution_event", event_json))
302
303
 
303
304
 
304
305
  def __process_progress_event(gt_event: ProgressEvent) -> None:
@@ -310,7 +311,7 @@ def __process_progress_event(gt_event: ProgressEvent) -> None:
310
311
  node_name=node_name, parameter_name=gt_event.parameter_name, type=type(gt_event).__name__, value=value
311
312
  )
312
313
  event_to_emit = ExecutionEvent(payload=payload)
313
- socket_manager.emit("execution_event", event_to_emit.json())
314
+ __schedule_async_task(__emit_message("execution_event", event_to_emit.json()))
314
315
 
315
316
 
316
317
  def __process_app_event(event: AppEvent) -> None:
@@ -318,7 +319,7 @@ def __process_app_event(event: AppEvent) -> None:
318
319
  # Let Griptape Nodes broadcast it.
319
320
  GriptapeNodes.broadcast_app_event(event.payload)
320
321
 
321
- socket_manager.emit("app_event", event.json())
322
+ __schedule_async_task(__emit_message("app_event", event.json()))
322
323
 
323
324
 
324
325
  def _process_event_queue() -> None:
@@ -339,7 +340,8 @@ def _process_event_queue() -> None:
339
340
  event_queue.task_done()
340
341
 
341
342
 
342
- def __build_authorized_request(request: httpx.Request) -> httpx.Request:
343
+ def __create_async_websocket_connection() -> Any:
344
+ """Create an async WebSocket connection to the Nodes API."""
343
345
  api_key = get_key(xdg_config_home() / "griptape_nodes" / ".env", "GT_CLOUD_API_KEY")
344
346
  if api_key is None:
345
347
  message = Panel(
@@ -354,33 +356,63 @@ def __build_authorized_request(request: httpx.Request) -> httpx.Request:
354
356
  )
355
357
  console.print(message)
356
358
  sys.exit(1)
357
- request.headers.update(
358
- {
359
- "Accept": "text/event-stream",
360
- "Authorization": f"Bearer {api_key}",
361
- }
359
+
360
+ endpoint = urljoin(
361
+ os.getenv("GRIPTAPE_NODES_API_BASE_URL", "https://api.nodes.griptape.ai").replace("http", "ws"),
362
+ "/ws/engines/events?publish_channel=responses&subscribe_channel=requests",
362
363
  )
363
- return request
364
364
 
365
+ return connect(
366
+ endpoint,
367
+ additional_headers={"Authorization": f"Bearer {api_key}"},
368
+ )
365
369
 
366
- def __check_api_key_validity(response: httpx.Response) -> None:
367
- """Check if the API key is valid by checking the response status code.
368
370
 
369
- If the API key is invalid, print an error message and exit the program.
370
- """
371
- if response.status_code in {401, 403}:
372
- message = Panel(
373
- Align.center(
374
- "[bold red]Nodes API key is invalid, please run [code]gtn init[/code] with a valid key: [/bold red]"
375
- "[code]gtn init --api-key <your key>[/code]\n"
376
- "[bold red]You can generate a new key from [/bold red][bold blue][link=https://nodes.griptape.ai]https://nodes.griptape.ai[/link][/bold blue]",
377
- ),
378
- title="🔑 ❌ Invalid Nodes API Key",
379
- border_style="red",
380
- padding=(1, 4),
371
+ async def __emit_message(event_type: str, payload: str) -> None:
372
+ """Send a message via WebSocket asynchronously."""
373
+ global ws_connection_for_sending # noqa: PLW0602
374
+ if ws_connection_for_sending is None:
375
+ logger.warning("WebSocket connection not available for sending message")
376
+ return
377
+
378
+ try:
379
+ body = {"type": event_type, "payload": json.loads(payload) if payload else {}}
380
+ await ws_connection_for_sending.send(json.dumps(body))
381
+ except WebSocketException as e:
382
+ logger.error("Error sending event to Nodes API: %s", e)
383
+ except Exception as e:
384
+ logger.error("Unexpected error while sending event to Nodes API: %s", e)
385
+
386
+
387
+ async def __send_heartbeat(*, session_id: str | None, request: dict, ws_connection: Any) -> None:
388
+ """Send a heartbeat response via WebSocket."""
389
+ heartbeat_response = {
390
+ "request": request,
391
+ "result": {},
392
+ "request_type": "Heartbeat",
393
+ "event_type": "EventResultSuccess",
394
+ "result_type": "HeartbeatSuccess",
395
+ **({"session_id": session_id} if session_id is not None else {}),
396
+ }
397
+
398
+ body = {"type": "success_result", "payload": heartbeat_response}
399
+ try:
400
+ await ws_connection.send(json.dumps(body))
401
+ logger.debug(
402
+ "Responded to heartbeat request with session: %s and request: %s", session_id, request.get("request_id")
381
403
  )
382
- console.print(message)
383
- sys.exit(1)
404
+ except WebSocketException as e:
405
+ logger.error("Error sending heartbeat response: %s", e)
406
+ except Exception as e:
407
+ logger.error("Unexpected error while sending heartbeat response: %s", e)
408
+
409
+
410
+ def __schedule_async_task(coro: Any) -> None:
411
+ """Schedule an async coroutine to run in the event loop from a sync context."""
412
+ if event_loop and event_loop.is_running():
413
+ asyncio.run_coroutine_threadsafe(coro, event_loop)
414
+ else:
415
+ logger.warning("Event loop not available for scheduling async task")
384
416
 
385
417
 
386
418
  def __broadcast_app_initialization_complete(nodes_app_url: str) -> None:
@@ -414,7 +446,7 @@ def __broadcast_app_initialization_complete(nodes_app_url: str) -> None:
414
446
  console.print(message)
415
447
 
416
448
 
417
- def __process_api_event(data: Any) -> None:
449
+ def __process_api_event(data: dict) -> None:
418
450
  """Process API events and send them to the event queue."""
419
451
  try:
420
452
  data["request"]
@@ -15,18 +15,20 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
15
15
  def __init__(
16
16
  self,
17
17
  *,
18
- bucket_id: str | None = None,
18
+ bucket_id: str,
19
19
  base_url: str | None = None,
20
20
  api_key: str | None = None,
21
21
  headers: dict | None = None,
22
+ static_files_directory: str | None = None,
22
23
  ) -> None:
23
24
  """Initialize the GriptapeCloudStorageDriver.
24
25
 
25
26
  Args:
26
- bucket_id: The ID of the bucket to use. If not provided, a new bucket will be provisioned.
27
+ bucket_id: The ID of the bucket to use. Required.
27
28
  base_url: The base URL for the Griptape Cloud API. If not provided, it will be retrieved from the environment variable "GT_CLOUD_BASE_URL" or default to "https://cloud.griptape.ai".
28
29
  api_key: The API key for authentication. If not provided, it will be retrieved from the environment variable "GT_CLOUD_API_KEY".
29
30
  headers: Additional headers to include in the requests. If not provided, the default headers will be used.
31
+ static_files_directory: The directory path prefix for static files. If provided, file names will be prefixed with this path.
30
32
  """
31
33
  self.base_url = (
32
34
  base_url if base_url is not None else os.environ.get("GT_CLOUD_BASE_URL", "https://cloud.griptape.ai")
@@ -41,11 +43,26 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
41
43
  )
42
44
 
43
45
  self.bucket_id = bucket_id
46
+ self.static_files_directory = static_files_directory
47
+
48
+ def _get_full_file_path(self, file_name: str) -> str:
49
+ """Get the full file path including the static files directory prefix.
50
+
51
+ Args:
52
+ file_name: The base file name.
53
+
54
+ Returns:
55
+ The full file path with static files directory prefix if configured.
56
+ """
57
+ if self.static_files_directory:
58
+ return f"{self.static_files_directory}/{file_name}"
59
+ return file_name
44
60
 
45
61
  def create_signed_upload_url(self, file_name: str) -> CreateSignedUploadUrlResponse:
46
- self._create_asset(file_name)
62
+ full_file_path = self._get_full_file_path(file_name)
63
+ self._create_asset(full_file_path)
47
64
 
48
- url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{file_name}")
65
+ url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{full_file_path}")
49
66
  try:
50
67
  response = httpx.post(url, json={"operation": "PUT"}, headers=self.headers)
51
68
  response.raise_for_status()
@@ -59,7 +76,8 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
59
76
  return {"url": response_data["url"], "headers": response_data.get("headers", {}), "method": "PUT"}
60
77
 
61
78
  def create_signed_download_url(self, file_name: str) -> str:
62
- url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{file_name}")
79
+ full_file_path = self._get_full_file_path(file_name)
80
+ url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{full_file_path}")
63
81
  try:
64
82
  response = httpx.post(url, json={"method": "GET"}, headers=self.headers)
65
83
  response.raise_for_status()
@@ -83,3 +101,60 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
83
101
  raise ValueError(msg) from e
84
102
 
85
103
  return response.json()["name"]
104
+
105
+ @staticmethod
106
+ def create_bucket(bucket_name: str, *, base_url: str, api_key: str) -> str:
107
+ """Create a new bucket in Griptape Cloud.
108
+
109
+ Args:
110
+ bucket_name: Name for the bucket.
111
+ base_url: The base URL for the Griptape Cloud API.
112
+ api_key: The API key for authentication.
113
+
114
+ Returns:
115
+ The bucket ID of the created bucket.
116
+
117
+ Raises:
118
+ RuntimeError: If bucket creation fails.
119
+ """
120
+ headers = {"Authorization": f"Bearer {api_key}"}
121
+ url = urljoin(base_url, "/api/buckets")
122
+ payload = {"name": bucket_name}
123
+
124
+ try:
125
+ response = httpx.post(url, json=payload, headers=headers)
126
+ response.raise_for_status()
127
+ except httpx.HTTPStatusError as e:
128
+ msg = f"Failed to create bucket '{bucket_name}': {e}"
129
+ logger.error(msg)
130
+ raise RuntimeError(msg) from e
131
+
132
+ response_data = response.json()
133
+ bucket_id = response_data["bucket_id"]
134
+
135
+ logger.info("Created new Griptape Cloud bucket '%s' with ID: %s", bucket_name, bucket_id)
136
+ return bucket_id
137
+
138
+ @staticmethod
139
+ def list_buckets(*, base_url: str, api_key: str) -> list[dict]:
140
+ """List all buckets in Griptape Cloud.
141
+
142
+ Args:
143
+ base_url: The base URL for the Griptape Cloud API.
144
+ api_key: The API key for authentication.
145
+
146
+ Returns:
147
+ A list of dictionaries containing bucket information.
148
+ """
149
+ headers = {"Authorization": f"Bearer {api_key}"}
150
+ url = urljoin(base_url, "/api/buckets")
151
+
152
+ try:
153
+ response = httpx.get(url, headers=headers)
154
+ response.raise_for_status()
155
+ except httpx.HTTPStatusError as e:
156
+ msg = f"Failed to list buckets: {e}"
157
+ logger.error(msg)
158
+ raise RuntimeError(msg) from e
159
+
160
+ return response.json().get("buckets", [])
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import time
2
3
  from urllib.parse import urljoin
3
4
 
4
5
  import httpx
@@ -46,4 +47,7 @@ class LocalStorageDriver(BaseStorageDriver):
46
47
  return {"url": url, "headers": response_data.get("headers", {}), "method": "PUT"}
47
48
 
48
49
  def create_signed_download_url(self, file_name: str) -> str:
49
- return urljoin(self.base_url, f"/static/{file_name}")
50
+ url = urljoin(self.base_url, f"/static/{file_name}")
51
+ # Add a cache-busting query parameter to the URL so that the browser always reloads the file
52
+ cache_busted_url = urljoin(url, f"?t={int(time.time())}")
53
+ return cache_busted_url
@@ -164,10 +164,12 @@ class BaseNodeElement:
164
164
  element_type: str = field(default_factory=lambda: BaseNodeElement.__name__)
165
165
  name: str = field(default_factory=lambda: str(f"{BaseNodeElement.__name__}_{uuid.uuid4().hex}"))
166
166
  parent_group_name: str | None = None
167
+ _changes: dict[str, Any] = field(default_factory=dict)
167
168
 
168
169
  _children: list[BaseNodeElement] = field(default_factory=list)
169
170
  _stack: ClassVar[list[BaseNodeElement]] = []
170
171
  _parent: BaseNodeElement | None = field(default=None)
172
+ _node_context: BaseNode | None = field(default=None)
171
173
 
172
174
  @property
173
175
  def children(self) -> list[BaseNodeElement]:
@@ -199,6 +201,59 @@ class BaseNodeElement:
199
201
  def __repr__(self) -> str:
200
202
  return f"BaseNodeElement({self.children=})"
201
203
 
204
+ def get_changes(self) -> dict[str, Any]:
205
+ return self._changes
206
+
207
+ @staticmethod
208
+ def emits_update_on_write(func: Callable) -> Callable:
209
+ """Decorator for properties that should track changes and emit events."""
210
+
211
+ def wrapper(self: BaseNodeElement, *args, **kwargs) -> Callable:
212
+ # For setters, track the change
213
+ if len(args) >= 1: # setter with value
214
+ old_value = getattr(self, f"{func.__name__}", None) if hasattr(self, f"{func.__name__}") else None
215
+ result = func(self, *args, **kwargs)
216
+ new_value = getattr(self, f"{func.__name__}", None) if hasattr(self, f"{func.__name__}") else None
217
+ # Track change if different
218
+ if old_value != new_value:
219
+ # it needs to be static so we can call these methods.
220
+ self._changes[func.__name__] = new_value
221
+ if self._node_context is not None and self not in self._node_context._tracked_parameters:
222
+ self._node_context._tracked_parameters.append(self)
223
+ return result
224
+ return func(self, *args, **kwargs)
225
+
226
+ return wrapper
227
+
228
+ def _emit_alter_element_event_if_possible(self) -> None:
229
+ """Emit an AlterElementEvent if we have node context and the necessary dependencies."""
230
+ if self._node_context is None:
231
+ return
232
+
233
+ # Import here to avoid circular dependencies
234
+ from griptape.events import EventBus
235
+
236
+ from griptape_nodes.retained_mode.events.base_events import ExecutionEvent, ExecutionGriptapeNodeEvent
237
+ from griptape_nodes.retained_mode.events.parameter_events import AlterElementEvent
238
+
239
+ # Create base event data using the existing to_event method
240
+ # Create a modified event data that only includes changed fields
241
+ event_data = {
242
+ # Include base fields that should always be present
243
+ "element_id": self.element_id,
244
+ "element_type": self.element_type,
245
+ "name": self.name,
246
+ "node_name": self._node_context.name,
247
+ }
248
+ event_data.update(self._changes)
249
+
250
+ # Publish the event
251
+ event = ExecutionGriptapeNodeEvent(
252
+ wrapped_event=ExecutionEvent(payload=AlterElementEvent(element_details=event_data))
253
+ )
254
+ EventBus.publish_event(event)
255
+ self._changes.clear()
256
+
202
257
  def to_dict(self) -> dict[str, Any]:
203
258
  """Returns a nested dictionary representation of this node and its children.
204
259
 
@@ -228,8 +283,18 @@ class BaseNodeElement:
228
283
  if child._parent is not None:
229
284
  child._parent.remove_child(child)
230
285
  child._parent = self
286
+ # Propagate node context to children
287
+ child._node_context = self._node_context
231
288
  self._children.append(child)
232
289
 
290
+ # Also propagate to any existing children of the child
291
+ for grandchild in child.find_elements_by_type(BaseNodeElement, find_recursively=True):
292
+ grandchild._node_context = self._node_context
293
+
294
+ # Emit event if we have node context
295
+ if self._node_context is not None:
296
+ self._node_context._emit_parameter_lifecycle_event(child)
297
+
233
298
  def remove_child(self, child: BaseNodeElement | str) -> None:
234
299
  ui_elements: list[BaseNodeElement] = [self]
235
300
  for ui_element in ui_elements:
@@ -238,6 +303,8 @@ class BaseNodeElement:
238
303
  ui_element._children.remove(child)
239
304
  break
240
305
  ui_elements.extend(ui_element._children)
306
+ if self._node_context is not None and isinstance(child, BaseNodeElement):
307
+ self._node_context._emit_parameter_lifecycle_event(child, remove=True)
241
308
 
242
309
  def find_element_by_id(self, element_id: str) -> BaseNodeElement | None:
243
310
  if self.element_id == element_id:
@@ -488,7 +555,7 @@ class Parameter(BaseNodeElement):
488
555
  tooltip_as_output: str | list[dict] | None = None
489
556
  settable: bool = True
490
557
  user_defined: bool = False
491
- allowed_modes: set = field(
558
+ _allowed_modes: set = field(
492
559
  default_factory=lambda: {
493
560
  ParameterMode.OUTPUT,
494
561
  ParameterMode.INPUT,
@@ -539,9 +606,9 @@ class Parameter(BaseNodeElement):
539
606
  self.settable = settable
540
607
  self.user_defined = user_defined
541
608
  if allowed_modes is None:
542
- self.allowed_modes = {ParameterMode.INPUT, ParameterMode.OUTPUT, ParameterMode.PROPERTY}
609
+ self._allowed_modes = {ParameterMode.INPUT, ParameterMode.OUTPUT, ParameterMode.PROPERTY}
543
610
  else:
544
- self.allowed_modes = allowed_modes
611
+ self._allowed_modes = allowed_modes
545
612
 
546
613
  if converters is None:
547
614
  self._converters = []
@@ -626,6 +693,7 @@ class Parameter(BaseNodeElement):
626
693
  return ParameterTypeBuiltin.STR.value
627
694
 
628
695
  @type.setter
696
+ @BaseNodeElement.emits_update_on_write
629
697
  def type(self, value: str | None) -> None:
630
698
  self._custom_setter_for_property_type(value)
631
699
 
@@ -659,6 +727,15 @@ class Parameter(BaseNodeElement):
659
727
  validators += self._validators
660
728
  return validators
661
729
 
730
+ @property
731
+ def allowed_modes(self) -> set[ParameterMode]:
732
+ return self._allowed_modes
733
+
734
+ @allowed_modes.setter
735
+ @BaseNodeElement.emits_update_on_write
736
+ def allowed_modes(self, value: Any) -> None:
737
+ self._allowed_modes = value
738
+
662
739
  @property
663
740
  def ui_options(self) -> dict:
664
741
  ui_options = {}
@@ -671,6 +748,7 @@ class Parameter(BaseNodeElement):
671
748
  return ui_options
672
749
 
673
750
  @ui_options.setter
751
+ @BaseNodeElement.emits_update_on_write
674
752
  def ui_options(self, value: dict) -> None:
675
753
  self._ui_options = value
676
754
 
@@ -689,6 +767,7 @@ class Parameter(BaseNodeElement):
689
767
  return [ParameterTypeBuiltin.STR.value]
690
768
 
691
769
  @input_types.setter
770
+ @BaseNodeElement.emits_update_on_write
692
771
  def input_types(self, value: list[str] | None) -> None:
693
772
  self._custom_setter_for_property_input_types(value)
694
773
 
@@ -726,6 +805,7 @@ class Parameter(BaseNodeElement):
726
805
  return ParameterTypeBuiltin.STR.value
727
806
 
728
807
  @output_type.setter
808
+ @BaseNodeElement.emits_update_on_write
729
809
  def output_type(self, value: str | None) -> None:
730
810
  self._custom_setter_for_property_output_type(value)
731
811
 
@@ -777,6 +857,7 @@ class Parameter(BaseNodeElement):
777
857
  def is_outgoing_type_allowed(self, target_type: str | None) -> bool:
778
858
  return ParameterType.are_types_compatible(source_type=self.output_type, target_type=target_type)
779
859
 
860
+ @BaseNodeElement.emits_update_on_write
780
861
  def set_default_value(self, value: Any) -> None:
781
862
  self.default_value = value
782
863