griptape-nodes 0.40.0__py3-none-any.whl → 0.42.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 (49) hide show
  1. griptape_nodes/app/__init__.py +1 -5
  2. griptape_nodes/app/app.py +12 -9
  3. griptape_nodes/app/app_sessions.py +132 -36
  4. griptape_nodes/app/watch.py +3 -1
  5. griptape_nodes/drivers/storage/local_storage_driver.py +3 -2
  6. griptape_nodes/exe_types/flow.py +68 -368
  7. griptape_nodes/machines/control_flow.py +16 -13
  8. griptape_nodes/machines/node_resolution.py +16 -14
  9. griptape_nodes/node_library/workflow_registry.py +2 -2
  10. griptape_nodes/retained_mode/events/agent_events.py +70 -8
  11. griptape_nodes/retained_mode/events/app_events.py +132 -11
  12. griptape_nodes/retained_mode/events/arbitrary_python_events.py +23 -0
  13. griptape_nodes/retained_mode/events/base_events.py +7 -25
  14. griptape_nodes/retained_mode/events/config_events.py +87 -11
  15. griptape_nodes/retained_mode/events/connection_events.py +56 -5
  16. griptape_nodes/retained_mode/events/context_events.py +27 -4
  17. griptape_nodes/retained_mode/events/execution_events.py +99 -14
  18. griptape_nodes/retained_mode/events/flow_events.py +165 -7
  19. griptape_nodes/retained_mode/events/library_events.py +193 -15
  20. griptape_nodes/retained_mode/events/logger_events.py +11 -0
  21. griptape_nodes/retained_mode/events/node_events.py +243 -22
  22. griptape_nodes/retained_mode/events/object_events.py +40 -4
  23. griptape_nodes/retained_mode/events/os_events.py +13 -2
  24. griptape_nodes/retained_mode/events/parameter_events.py +212 -8
  25. griptape_nodes/retained_mode/events/secrets_events.py +59 -7
  26. griptape_nodes/retained_mode/events/static_file_events.py +57 -4
  27. griptape_nodes/retained_mode/events/validation_events.py +39 -4
  28. griptape_nodes/retained_mode/events/workflow_events.py +188 -17
  29. griptape_nodes/retained_mode/griptape_nodes.py +46 -323
  30. griptape_nodes/retained_mode/managers/agent_manager.py +1 -1
  31. griptape_nodes/retained_mode/managers/engine_identity_manager.py +146 -0
  32. griptape_nodes/retained_mode/managers/event_manager.py +14 -2
  33. griptape_nodes/retained_mode/managers/flow_manager.py +749 -64
  34. griptape_nodes/retained_mode/managers/library_manager.py +112 -2
  35. griptape_nodes/retained_mode/managers/node_manager.py +35 -32
  36. griptape_nodes/retained_mode/managers/object_manager.py +11 -3
  37. griptape_nodes/retained_mode/managers/os_manager.py +70 -1
  38. griptape_nodes/retained_mode/managers/secrets_manager.py +4 -0
  39. griptape_nodes/retained_mode/managers/session_manager.py +328 -0
  40. griptape_nodes/retained_mode/managers/settings.py +7 -0
  41. griptape_nodes/retained_mode/managers/workflow_manager.py +523 -454
  42. griptape_nodes/retained_mode/retained_mode.py +44 -0
  43. griptape_nodes/retained_mode/utils/engine_identity.py +141 -27
  44. {griptape_nodes-0.40.0.dist-info → griptape_nodes-0.42.0.dist-info}/METADATA +2 -2
  45. {griptape_nodes-0.40.0.dist-info → griptape_nodes-0.42.0.dist-info}/RECORD +48 -47
  46. griptape_nodes/retained_mode/utils/session_persistence.py +0 -105
  47. {griptape_nodes-0.40.0.dist-info → griptape_nodes-0.42.0.dist-info}/WHEEL +0 -0
  48. {griptape_nodes-0.40.0.dist-info → griptape_nodes-0.42.0.dist-info}/entry_points.txt +0 -0
  49. {griptape_nodes-0.40.0.dist-info → griptape_nodes-0.42.0.dist-info}/licenses/LICENSE +0 -0
@@ -2,11 +2,7 @@
2
2
 
3
3
  import os
4
4
 
5
- if os.getenv("GTN_USE_SESSIONS", "False").lower() == "true":
6
- # Sessions are only available in the staging environment
7
- os.environ["GRIPTAPE_NODES_API_BASE_URL"] = os.getenv(
8
- "GRIPTAPE_NODES_API_BASE_URL", "https://api.nodes-staging.griptape.ai"
9
- )
5
+ if os.getenv("GTN_USE_SESSIONS", "True").lower() == "true":
10
6
  from griptape_nodes.app.app_sessions import start_app
11
7
  else:
12
8
  from griptape_nodes.app.app import start_app
griptape_nodes/app/app.py CHANGED
@@ -149,28 +149,31 @@ def _serve_static_server() -> None:
149
149
 
150
150
  return {"url": url}
151
151
 
152
- @app.put("/static-uploads/{file_name:str}")
153
- async def create_static_file(request: Request, file_name: str) -> dict:
152
+ @app.put("/static-uploads/{file_path:path}")
153
+ async def create_static_file(request: Request, file_path: str) -> dict:
154
154
  """Upload a static file to the static server."""
155
155
  if not STATIC_SERVER_ENABLED:
156
156
  msg = "Static server is not enabled. Please set STATIC_SERVER_ENABLED to True."
157
157
  raise ValueError(msg)
158
158
 
159
- if not static_dir.exists():
160
- static_dir.mkdir(parents=True, exist_ok=True)
159
+ file_full_path = Path(static_dir / file_path)
160
+
161
+ # Create parent directories if they don't exist
162
+ file_full_path.parent.mkdir(parents=True, exist_ok=True)
163
+
161
164
  data = await request.body()
162
165
  try:
163
- Path(static_dir / file_name).write_bytes(data)
166
+ file_full_path.write_bytes(data)
164
167
  except binascii.Error as e:
165
- msg = f"Invalid base64 encoding for file {file_name}."
168
+ msg = f"Invalid base64 encoding for file {file_path}."
166
169
  logger.error(msg)
167
170
  raise HTTPException(status_code=400, detail=msg) from e
168
171
  except (OSError, PermissionError) as e:
169
- msg = f"Failed to write file {file_name} to {config_manager.workspace_path}: {e}"
172
+ msg = f"Failed to write file {file_path} to {config_manager.workspace_path}: {e}"
170
173
  logger.error(msg)
171
174
  raise HTTPException(status_code=500, detail=msg) from e
172
175
 
173
- static_url = f"http://{STATIC_SERVER_HOST}:{STATIC_SERVER_PORT}{STATIC_SERVER_URL}/{file_name}"
176
+ static_url = f"http://{STATIC_SERVER_HOST}:{STATIC_SERVER_PORT}{STATIC_SERVER_URL}/{file_path}"
174
177
  return {"url": static_url}
175
178
 
176
179
  @app.post("/engines/request")
@@ -422,7 +425,7 @@ def __broadcast_app_initialization_complete(nodes_app_url: str) -> None:
422
425
  # Broadcast this to anybody who wants a callback on "hey, the app's ready to roll"
423
426
  payload = app_events.AppInitializationComplete()
424
427
  app_event = AppEvent(payload=payload)
425
- __process_app_event(app_event)
428
+ event_queue.put(app_event)
426
429
 
427
430
  engine_version_request = app_events.GetEngineVersionRequest()
428
431
  engine_version_result = GriptapeNodes.get_instance().handle_engine_version_request(engine_version_request)
@@ -30,6 +30,7 @@ from websockets.exceptions import ConnectionClosed, WebSocketException
30
30
 
31
31
  # This import is necessary to register all events, even if not technically used
32
32
  from griptape_nodes.retained_mode.events import app_events, execution_events
33
+ from griptape_nodes.retained_mode.events.app_events import AppEndSessionResultSuccess, AppStartSessionResultSuccess
33
34
  from griptape_nodes.retained_mode.events.base_events import (
34
35
  AppEvent,
35
36
  EventRequest,
@@ -149,35 +150,37 @@ def _serve_static_server() -> None:
149
150
 
150
151
  return {"url": url}
151
152
 
152
- @app.put("/static-uploads/{file_name:str}")
153
- async def create_static_file(request: Request, file_name: str) -> dict:
153
+ @app.put("/static-uploads/{file_path:path}")
154
+ async def create_static_file(request: Request, file_path: str) -> dict:
154
155
  """Upload a static file to the static server."""
155
156
  if not STATIC_SERVER_ENABLED:
156
157
  msg = "Static server is not enabled. Please set STATIC_SERVER_ENABLED to True."
157
158
  raise ValueError(msg)
158
159
 
159
- if not static_dir.exists():
160
- static_dir.mkdir(parents=True, exist_ok=True)
160
+ file_full_path = Path(static_dir / file_path)
161
+
162
+ # Create parent directories if they don't exist
163
+ file_full_path.parent.mkdir(parents=True, exist_ok=True)
164
+
161
165
  data = await request.body()
162
166
  try:
163
- Path(static_dir / file_name).write_bytes(data)
167
+ file_full_path.write_bytes(data)
164
168
  except binascii.Error as e:
165
- msg = f"Invalid base64 encoding for file {file_name}."
169
+ msg = f"Invalid base64 encoding for file {file_path}."
166
170
  logger.error(msg)
167
171
  raise HTTPException(status_code=400, detail=msg) from e
168
172
  except (OSError, PermissionError) as e:
169
- msg = f"Failed to write file {file_name} to {config_manager.workspace_path}: {e}"
173
+ msg = f"Failed to write file {file_path} to {config_manager.workspace_path}: {e}"
170
174
  logger.error(msg)
171
175
  raise HTTPException(status_code=500, detail=msg) from e
172
176
 
173
- static_url = f"http://{STATIC_SERVER_HOST}:{STATIC_SERVER_PORT}{STATIC_SERVER_URL}/{file_name}"
177
+ static_url = f"http://{STATIC_SERVER_HOST}:{STATIC_SERVER_PORT}{STATIC_SERVER_URL}/{file_path}"
174
178
  return {"url": static_url}
175
179
 
176
180
  @app.post("/engines/request")
177
181
  async def create_event(request: Request) -> None:
178
182
  body = await request.json()
179
- if "payload" in body:
180
- __process_api_event(body["payload"])
183
+ __process_api_event(body)
181
184
 
182
185
  logging.getLogger("uvicorn").addHandler(
183
186
  RichHandler(show_time=True, show_path=False, markup=True, rich_tracebacks=True)
@@ -229,16 +232,19 @@ async def _alisten_for_api_requests() -> None:
229
232
  async for ws_connection in connection_stream:
230
233
  try:
231
234
  ws_connection_for_sending = ws_connection # Store for sending events
235
+
232
236
  if not initialized:
233
237
  __broadcast_app_initialization_complete(nodes_app_url)
234
238
  initialized = True
235
239
 
240
+ # Subscribe to engine ID and session ID on every new connection
241
+ await __subscribe_to_engine_and_session(ws_connection)
242
+
236
243
  async for message in ws_connection:
237
244
  try:
238
245
  data = json.loads(message)
239
246
 
240
- payload = data.get("payload", {})
241
- __process_api_event(payload)
247
+ __process_api_event(data)
242
248
  except Exception:
243
249
  logger.exception("Error processing event, skipping.")
244
250
  except ConnectionClosed:
@@ -259,6 +265,8 @@ def __process_node_event(event: GriptapeNodeEvent) -> None:
259
265
  result_event = event.wrapped_event
260
266
  if isinstance(result_event, EventResultSuccess):
261
267
  dest_socket = "success_result"
268
+ # Handle session start events specially
269
+ __handle_session_events(result_event)
262
270
  elif isinstance(result_event, EventResultFailure):
263
271
  dest_socket = "failure_result"
264
272
  else:
@@ -266,8 +274,28 @@ def __process_node_event(event: GriptapeNodeEvent) -> None:
266
274
  raise TypeError(msg) from None
267
275
 
268
276
  # Don't send events over the wire that don't have a request_id set (e.g. engine-internal events)
269
- event_json = result_event.json()
270
- __schedule_async_task(__emit_message(dest_socket, event_json))
277
+ __schedule_async_task(__emit_message(dest_socket, result_event.json(), topic=result_event.response_topic))
278
+
279
+
280
+ def __handle_session_events(result_event: EventResultSuccess) -> None:
281
+ """Handle session start/end events by managing subscriptions."""
282
+ global ws_connection_for_sending # noqa: PLW0602
283
+
284
+ if isinstance(result_event.result, AppStartSessionResultSuccess):
285
+ # Subscribe to the new session topic
286
+ session_id = result_event.result.session_id
287
+ if session_id and ws_connection_for_sending:
288
+ topic = f"sessions/{session_id}/request"
289
+ __schedule_async_task(__subscribe_to_topic(ws_connection_for_sending, topic))
290
+ logger.info("Subscribed to new session topic: %s", topic)
291
+
292
+ elif isinstance(result_event.result, AppEndSessionResultSuccess):
293
+ # Unsubscribe from the ended session topic
294
+ session_id = result_event.result.session_id
295
+ if session_id and ws_connection_for_sending:
296
+ topic = f"sessions/{session_id}/request"
297
+ __schedule_async_task(__unsubscribe_from_topic(ws_connection_for_sending, topic))
298
+ logger.info("Unsubscribed from ended session topic: %s", topic)
271
299
 
272
300
 
273
301
  def __process_execution_node_event(event: ExecutionGriptapeNodeEvent) -> None:
@@ -275,7 +303,6 @@ def __process_execution_node_event(event: ExecutionGriptapeNodeEvent) -> None:
275
303
  result_event = event.wrapped_event
276
304
  if type(result_event.payload).__name__ == "NodeStartProcessEvent":
277
305
  GriptapeNodes.EventManager().current_active_node = result_event.payload.node_name
278
- event_json = result_event.json()
279
306
 
280
307
  if type(result_event.payload).__name__ == "ResumeNodeProcessingEvent":
281
308
  node_name = result_event.payload.node_name
@@ -289,7 +316,7 @@ def __process_execution_node_event(event: ExecutionGriptapeNodeEvent) -> None:
289
316
  msg = "Node start and finish do not match."
290
317
  raise KeyError(msg) from None
291
318
  GriptapeNodes.EventManager().current_active_node = None
292
- __schedule_async_task(__emit_message("execution_event", event_json))
319
+ __schedule_async_task(__emit_message("execution_event", result_event.json()))
293
320
 
294
321
 
295
322
  def __process_progress_event(gt_event: ProgressEvent) -> None:
@@ -321,7 +348,9 @@ def _process_event_queue() -> None:
321
348
  event = event_queue.get(block=True)
322
349
  if isinstance(event, EventRequest):
323
350
  request_payload = event.request
324
- GriptapeNodes.handle_request(request_payload)
351
+ GriptapeNodes.handle_request(
352
+ request_payload, response_topic=event.response_topic, request_id=event.request_id
353
+ )
325
354
  elif isinstance(event, AppEvent):
326
355
  __process_app_event(event)
327
356
  else:
@@ -350,7 +379,7 @@ def __create_async_websocket_connection() -> Any:
350
379
 
351
380
  endpoint = urljoin(
352
381
  os.getenv("GRIPTAPE_NODES_API_BASE_URL", "https://api.nodes.griptape.ai").replace("http", "ws"),
353
- "/ws/engines/events?publish_channel=responses&subscribe_channel=requests",
382
+ "/ws/engines/events?version=v2",
354
383
  )
355
384
 
356
385
  return connect(
@@ -359,7 +388,7 @@ def __create_async_websocket_connection() -> Any:
359
388
  )
360
389
 
361
390
 
362
- async def __emit_message(event_type: str, payload: str) -> None:
391
+ async def __emit_message(event_type: str, payload: str, topic: str | None = None) -> None:
363
392
  """Send a message via WebSocket asynchronously."""
364
393
  global ws_connection_for_sending # noqa: PLW0602
365
394
  if ws_connection_for_sending is None:
@@ -367,7 +396,12 @@ async def __emit_message(event_type: str, payload: str) -> None:
367
396
  return
368
397
 
369
398
  try:
370
- body = {"type": event_type, "payload": json.loads(payload) if payload else {}}
399
+ # Determine topic based on session_id and engine_id in the payload
400
+ if topic is None:
401
+ topic = _determine_response_topic()
402
+
403
+ body = {"type": event_type, "payload": json.loads(payload), "topic": topic}
404
+
371
405
  await ws_connection_for_sending.send(json.dumps(body))
372
406
  except WebSocketException as e:
373
407
  logger.error("Error sending event to Nodes API: %s", e)
@@ -375,6 +409,78 @@ async def __emit_message(event_type: str, payload: str) -> None:
375
409
  logger.error("Unexpected error while sending event to Nodes API: %s", e)
376
410
 
377
411
 
412
+ def _determine_response_topic() -> str | None:
413
+ """Determine the response topic based on session_id and engine_id in the payload."""
414
+ engine_id = GriptapeNodes.get_engine_id()
415
+ session_id = GriptapeNodes.get_session_id()
416
+
417
+ # Normal topic determination logic
418
+ # Check for session_id first (highest priority)
419
+ if session_id:
420
+ return f"sessions/{session_id}/response"
421
+
422
+ # Check for engine_id if no session_id
423
+ if engine_id:
424
+ return f"engines/{engine_id}/response"
425
+
426
+ # Default to generic response topic
427
+ return "response"
428
+
429
+
430
+ async def __subscribe_to_topic(ws_connection: Any, topic: str) -> None:
431
+ """Subscribe to a specific topic in the message bus."""
432
+ if ws_connection is None:
433
+ logger.warning("WebSocket connection not available for subscribing to topic")
434
+ return
435
+
436
+ try:
437
+ body = {"type": "subscribe", "topic": topic, "payload": {}}
438
+ await ws_connection.send(json.dumps(body))
439
+ logger.info("Subscribed to topic: %s", topic)
440
+ except WebSocketException as e:
441
+ logger.error("Error subscribing to topic %s: %s", topic, e)
442
+ except Exception as e:
443
+ logger.error("Unexpected error while subscribing to topic %s: %s", topic, e)
444
+
445
+
446
+ async def __unsubscribe_from_topic(ws_connection: Any, topic: str) -> None:
447
+ """Unsubscribe from a specific topic in the message bus."""
448
+ if ws_connection is None:
449
+ logger.warning("WebSocket connection not available for unsubscribing from topic")
450
+ return
451
+
452
+ try:
453
+ body = {"type": "unsubscribe", "topic": topic, "payload": {}}
454
+ await ws_connection.send(json.dumps(body))
455
+ logger.info("Unsubscribed from topic: %s", topic)
456
+ except WebSocketException as e:
457
+ logger.error("Error unsubscribing from topic %s: %s", topic, e)
458
+ except Exception as e:
459
+ logger.error("Unexpected error while unsubscribing from topic %s: %s", topic, e)
460
+
461
+
462
+ async def __subscribe_to_engine_and_session(ws_connection: Any) -> None:
463
+ """Subscribe to engine ID, session ID, and request topics on WebSocket connection."""
464
+ # Subscribe to request topic (engine discovery)
465
+ await __subscribe_to_topic(ws_connection, "request")
466
+
467
+ # Get engine ID and subscribe to engine_id/request
468
+ engine_id = GriptapeNodes.get_engine_id()
469
+ if engine_id:
470
+ await __subscribe_to_topic(ws_connection, f"engines/{engine_id}/request")
471
+ else:
472
+ logger.warning("Engine ID not available for subscription")
473
+
474
+ # Get session ID and subscribe to session_id/request if available
475
+ session_id = GriptapeNodes.get_session_id()
476
+ if session_id:
477
+ topic = f"sessions/{session_id}/request"
478
+ await __subscribe_to_topic(ws_connection, topic)
479
+ logger.info("Subscribed to session topic: %s", topic)
480
+ else:
481
+ logger.info("No session ID available for subscription")
482
+
483
+
378
484
  def __schedule_async_task(coro: Any) -> None:
379
485
  """Schedule an async coroutine to run in the event loop from a sync context."""
380
486
  if event_loop and event_loop.is_running():
@@ -388,12 +494,6 @@ def __broadcast_app_initialization_complete(nodes_app_url: str) -> None:
388
494
 
389
495
  This is used to notify the GUI that the app is ready to receive events.
390
496
  """
391
- # Initialize engine ID and persistent data
392
- from griptape_nodes.retained_mode.events.base_events import BaseEvent
393
-
394
- BaseEvent.initialize_engine_id()
395
- BaseEvent.initialize_session_id()
396
-
397
497
  # Broadcast this to anybody who wants a callback on "hey, the app's ready to roll"
398
498
  payload = app_events.AppInitializationComplete()
399
499
  app_event = AppEvent(payload=payload)
@@ -424,16 +524,18 @@ def __broadcast_app_initialization_complete(nodes_app_url: str) -> None:
424
524
  console.print(message)
425
525
 
426
526
 
427
- def __process_api_event(data: dict) -> None:
527
+ def __process_api_event(event: dict) -> None:
428
528
  """Process API events and send them to the event queue."""
529
+ payload = event.get("payload", {})
530
+
429
531
  try:
430
- data["request"]
532
+ payload["request"]
431
533
  except KeyError:
432
534
  msg = "Error: 'request' was expected but not found."
433
535
  raise RuntimeError(msg) from None
434
536
 
435
537
  try:
436
- event_type = data["event_type"]
538
+ event_type = payload["event_type"]
437
539
  if event_type != "EventRequest":
438
540
  msg = "Error: 'event_type' was found on request, but did not match 'EventRequest' as expected."
439
541
  raise RuntimeError(msg) from None
@@ -443,16 +545,10 @@ def __process_api_event(data: dict) -> None:
443
545
 
444
546
  # Now attempt to convert it into an EventRequest.
445
547
  try:
446
- request_event: EventRequest = cast("EventRequest", deserialize_event(json_data=data))
548
+ request_event: EventRequest = cast("EventRequest", deserialize_event(json_data=payload))
447
549
  except Exception as e:
448
550
  msg = f"Unable to convert request JSON into a valid EventRequest object. Error Message: '{e}'"
449
551
  raise RuntimeError(msg) from None
450
552
 
451
- # Add a request_id to the payload
452
- request_id = request_event.request.request_id
453
- request_event.request.request_id = request_id
454
-
455
553
  # Add the event to the queue
456
554
  event_queue.put(request_event)
457
-
458
- return request_id
@@ -52,7 +52,9 @@ if __name__ == "__main__":
52
52
  event_handler = ReloadHandler(patterns=["*.py"], ignore_patterns=["*.pyc", "*.pyo"], ignore_directories=True)
53
53
 
54
54
  observer = Observer()
55
- observer.schedule(event_handler, path=".", recursive=True)
55
+ observer.schedule(event_handler, path="src", recursive=True)
56
+ observer.schedule(event_handler, path="libraries", recursive=True)
57
+ observer.schedule(event_handler, path="tests", recursive=True)
56
58
  observer.start()
57
59
 
58
60
  try:
@@ -47,7 +47,8 @@ class LocalStorageDriver(BaseStorageDriver):
47
47
  return {"url": url, "headers": response_data.get("headers", {}), "method": "PUT"}
48
48
 
49
49
  def create_signed_download_url(self, file_name: str) -> str:
50
- url = urljoin(self.base_url, f"/static/{file_name}")
50
+ # The base_url already includes the /static path, so just append the filename
51
+ url = f"{self.base_url}/{file_name}"
51
52
  # 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
+ cache_busted_url = f"{url}?t={int(time.time())}"
53
54
  return cache_busted_url