griptape-nodes 0.51.2__py3-none-any.whl → 0.52.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 (46) hide show
  1. griptape_nodes/__init__.py +5 -4
  2. griptape_nodes/app/api.py +22 -30
  3. griptape_nodes/app/app.py +374 -289
  4. griptape_nodes/app/watch.py +17 -2
  5. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +66 -103
  6. griptape_nodes/bootstrap/workflow_executors/workflow_executor.py +16 -4
  7. griptape_nodes/exe_types/core_types.py +16 -4
  8. griptape_nodes/exe_types/node_types.py +74 -16
  9. griptape_nodes/machines/control_flow.py +21 -26
  10. griptape_nodes/machines/fsm.py +16 -16
  11. griptape_nodes/machines/node_resolution.py +30 -119
  12. griptape_nodes/mcp_server/server.py +14 -10
  13. griptape_nodes/mcp_server/ws_request_manager.py +2 -2
  14. griptape_nodes/node_library/workflow_registry.py +5 -0
  15. griptape_nodes/retained_mode/events/base_events.py +12 -7
  16. griptape_nodes/retained_mode/events/execution_events.py +0 -6
  17. griptape_nodes/retained_mode/events/node_events.py +38 -0
  18. griptape_nodes/retained_mode/events/parameter_events.py +11 -0
  19. griptape_nodes/retained_mode/events/variable_events.py +361 -0
  20. griptape_nodes/retained_mode/events/workflow_events.py +35 -0
  21. griptape_nodes/retained_mode/griptape_nodes.py +61 -26
  22. griptape_nodes/retained_mode/managers/agent_manager.py +8 -9
  23. griptape_nodes/retained_mode/managers/event_manager.py +215 -74
  24. griptape_nodes/retained_mode/managers/flow_manager.py +39 -33
  25. griptape_nodes/retained_mode/managers/library_lifecycle/library_directory.py +14 -14
  26. griptape_nodes/retained_mode/managers/library_lifecycle/library_fsm.py +20 -20
  27. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/base.py +1 -1
  28. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/github.py +1 -1
  29. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/local_file.py +4 -3
  30. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/package.py +1 -1
  31. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/sandbox.py +1 -1
  32. griptape_nodes/retained_mode/managers/library_manager.py +20 -19
  33. griptape_nodes/retained_mode/managers/node_manager.py +83 -8
  34. griptape_nodes/retained_mode/managers/object_manager.py +4 -0
  35. griptape_nodes/retained_mode/managers/settings.py +1 -0
  36. griptape_nodes/retained_mode/managers/sync_manager.py +3 -9
  37. griptape_nodes/retained_mode/managers/variable_manager.py +529 -0
  38. griptape_nodes/retained_mode/managers/workflow_manager.py +156 -50
  39. griptape_nodes/retained_mode/variable_types.py +18 -0
  40. griptape_nodes/utils/__init__.py +4 -0
  41. griptape_nodes/utils/async_utils.py +89 -0
  42. {griptape_nodes-0.51.2.dist-info → griptape_nodes-0.52.1.dist-info}/METADATA +2 -3
  43. {griptape_nodes-0.51.2.dist-info → griptape_nodes-0.52.1.dist-info}/RECORD +45 -42
  44. {griptape_nodes-0.51.2.dist-info → griptape_nodes-0.52.1.dist-info}/WHEEL +1 -1
  45. griptape_nodes/bootstrap/workflow_executors/subprocess_workflow_executor.py +0 -90
  46. {griptape_nodes-0.51.2.dist-info → griptape_nodes-0.52.1.dist-info}/entry_points.txt +0 -0
griptape_nodes/app/app.py CHANGED
@@ -4,18 +4,13 @@ import asyncio
4
4
  import json
5
5
  import logging
6
6
  import os
7
- import signal
8
7
  import sys
9
8
  import threading
9
+ from dataclasses import dataclass
10
10
  from pathlib import Path
11
- from queue import Queue
12
- from typing import Any, cast
11
+ from typing import Any
13
12
  from urllib.parse import urljoin
14
13
 
15
- from griptape.events import (
16
- EventBus,
17
- EventListener,
18
- )
19
14
  from rich.align import Align
20
15
  from rich.console import Console
21
16
  from rich.logging import RichHandler
@@ -23,7 +18,8 @@ from rich.panel import Panel
23
18
  from websockets.asyncio.client import connect
24
19
  from websockets.exceptions import ConnectionClosed, WebSocketException
25
20
 
26
- from griptape_nodes.mcp_server.server import main as mcp_server
21
+ from griptape_nodes.app.api import start_static_server
22
+ from griptape_nodes.mcp_server.server import start_mcp_server
27
23
  from griptape_nodes.retained_mode.events import app_events, execution_events
28
24
 
29
25
  # This import is necessary to register all events, even if not technically used
@@ -36,29 +32,61 @@ from griptape_nodes.retained_mode.events.base_events import (
36
32
  ExecutionGriptapeNodeEvent,
37
33
  GriptapeNodeEvent,
38
34
  ProgressEvent,
39
- RequestPayload,
40
35
  SkipTheLineMixin,
41
36
  deserialize_event,
42
37
  )
43
38
  from griptape_nodes.retained_mode.events.logger_events import LogHandlerEvent
44
39
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
45
40
 
46
- from .api import start_api
47
41
 
48
- # This is a global event queue that will be used to pass events between threads
49
- event_queue = Queue()
42
+ # WebSocket thread communication message types
43
+ @dataclass
44
+ class WebSocketMessage:
45
+ """Message to send via WebSocket."""
46
+
47
+ event_type: str
48
+ payload: str
49
+ topic: str | None = None
50
+
51
+
52
+ @dataclass
53
+ class SubscribeCommand:
54
+ """Command to subscribe to a topic."""
55
+
56
+ topic: str
57
+
58
+
59
+ @dataclass
60
+ class UnsubscribeCommand:
61
+ """Command to unsubscribe from a topic."""
50
62
 
51
- # Global WebSocket connection for sending events
52
- ws_connection_for_sending = None
53
- event_loop = None
63
+ topic: str
54
64
 
55
- # Event to signal when WebSocket connection is ready
56
- ws_ready_event = threading.Event()
65
+
66
+ # WebSocket outgoing queue for messages and commands.
67
+ # Appears to be fine to create outside event loop
68
+ # https://discuss.python.org/t/can-asyncio-queue-be-safely-created-outside-of-the-event-loop-thread/49215/8
69
+ ws_outgoing_queue: asyncio.Queue = asyncio.Queue()
70
+
71
+ # Background WebSocket event loop reference for cross-thread communication
72
+ websocket_event_loop: asyncio.AbstractEventLoop | None = None
73
+
74
+ # Threading event to signal when websocket_event_loop is ready
75
+ websocket_event_loop_ready = threading.Event()
57
76
 
58
77
 
59
78
  # Whether to enable the static server
60
79
  STATIC_SERVER_ENABLED = os.getenv("STATIC_SERVER_ENABLED", "true").lower() == "true"
61
80
 
81
+ # Semaphore to limit concurrent requests
82
+ REQUEST_SEMAPHORE = asyncio.Semaphore(100)
83
+
84
+
85
+ # Important to bootstrap singleton here so that we don't
86
+ # get any weird circular import issues from the EventLogHandler
87
+ # initializing it from a log during it's own initialization.
88
+ griptape_nodes: GriptapeNodes = GriptapeNodes()
89
+
62
90
 
63
91
  class EventLogHandler(logging.Handler):
64
92
  """Custom logging handler that emits log messages as AppEvents.
@@ -67,11 +95,10 @@ class EventLogHandler(logging.Handler):
67
95
  """
68
96
 
69
97
  def emit(self, record: logging.LogRecord) -> None:
70
- event_queue.put(
71
- AppEvent(
72
- payload=LogHandlerEvent(message=record.getMessage(), levelname=record.levelname, created=record.created)
73
- )
98
+ log_event = AppEvent(
99
+ payload=LogHandlerEvent(message=record.getMessage(), levelname=record.levelname, created=record.created)
74
100
  )
101
+ griptape_nodes.EventManager().put_event(log_event)
75
102
 
76
103
 
77
104
  # Logger for this module. Important that this is not the same as the griptape_nodes logger or else we'll have infinite log events.
@@ -87,26 +114,101 @@ console = Console()
87
114
 
88
115
 
89
116
  def start_app() -> None:
90
- """Main entry point for the Griptape Nodes app.
117
+ """Legacy sync entry point - runs async app."""
118
+ try:
119
+ asyncio.run(astart_app())
120
+ except KeyboardInterrupt:
121
+ logger.info("Application stopped by user")
122
+ except Exception as e:
123
+ logger.error("Application error: %s", e)
91
124
 
92
- Starts the event loop and listens for events from the Nodes API.
93
- """
94
- _init_event_listeners()
95
- # Listen for any signals to exit the app
96
- for sig in (signal.SIGINT, signal.SIGTERM):
97
- signal.signal(sig, lambda *_: sys.exit(0))
98
125
 
126
+ async def astart_app() -> None:
127
+ """New async app entry point."""
99
128
  api_key = _ensure_api_key()
100
- threading.Thread(target=mcp_server, args=(api_key,), daemon=True).start()
101
- threading.Thread(target=_listen_for_api_events, args=(api_key,), daemon=True).start()
102
- if STATIC_SERVER_ENABLED:
103
- static_dir = _build_static_dir()
104
- threading.Thread(target=start_api, args=(static_dir, event_queue), daemon=True).start()
105
- _process_event_queue()
129
+
130
+ # Initialize event queue in main thread
131
+ griptape_nodes.EventManager().initialize_queue()
132
+
133
+ # Get main loop reference
134
+ main_loop = asyncio.get_running_loop()
135
+
136
+ try:
137
+ # Start MCP server in daemon thread
138
+ threading.Thread(target=start_mcp_server, args=(api_key,), daemon=True, name="mcp-server").start()
139
+
140
+ # Start static server in daemon thread if enabled
141
+ if STATIC_SERVER_ENABLED:
142
+ static_dir = _build_static_dir()
143
+ threading.Thread(target=start_static_server, args=(static_dir,), daemon=True, name="static-server").start()
144
+
145
+ # Start WebSocket tasks in daemon thread
146
+ threading.Thread(
147
+ target=_start_websocket_connection, args=(api_key, main_loop), daemon=True, name="websocket-tasks"
148
+ ).start()
149
+
150
+ # Run event processing on main thread
151
+ await _process_event_queue()
152
+
153
+ except Exception as e:
154
+ logger.error("Application startup failed: %s", e)
155
+ raise
156
+
157
+
158
+ def _start_websocket_connection(api_key: str, main_loop: asyncio.AbstractEventLoop) -> None:
159
+ """Run WebSocket tasks in a separate thread with its own async loop."""
160
+ global websocket_event_loop # noqa: PLW0603
161
+ try:
162
+ # Create a new event loop for this thread
163
+ loop = asyncio.new_event_loop()
164
+ websocket_event_loop = loop
165
+ asyncio.set_event_loop(loop)
166
+
167
+ # Signal that websocket_event_loop is ready
168
+ websocket_event_loop_ready.set()
169
+
170
+ # Run the async WebSocket tasks
171
+ loop.run_until_complete(_run_websocket_tasks(api_key, main_loop))
172
+ except Exception as e:
173
+ logger.error("WebSocket thread error: %s", e)
174
+ raise
175
+ finally:
176
+ websocket_event_loop = None
177
+ websocket_event_loop_ready.clear()
178
+
179
+
180
+ async def _run_websocket_tasks(api_key: str, main_loop: asyncio.AbstractEventLoop) -> None:
181
+ """Run WebSocket tasks - async version."""
182
+ # Create WebSocket connection for this thread
183
+ connection_stream = _create_websocket_connection(api_key)
184
+
185
+ # Track if this is the first connection
186
+ initialized = False
187
+
188
+ async for ws_connection in connection_stream:
189
+ try:
190
+ # Emit initialization event only for the first connection
191
+ if not initialized:
192
+ griptape_nodes.EventManager().put_event_threadsafe(
193
+ main_loop, AppEvent(payload=app_events.AppInitializationComplete())
194
+ )
195
+ initialized = True
196
+
197
+ # Emit connection established event for every connection
198
+ griptape_nodes.EventManager().put_event_threadsafe(
199
+ main_loop, AppEvent(payload=app_events.AppConnectionEstablished())
200
+ )
201
+
202
+ async with asyncio.TaskGroup() as tg:
203
+ tg.create_task(_process_incoming_messages(ws_connection, main_loop))
204
+ tg.create_task(_send_outgoing_messages(ws_connection))
205
+ except* Exception as e:
206
+ logger.error("WebSocket tasks failed: %s", e.exceptions)
207
+ await asyncio.sleep(2.0) # Wait before retry
106
208
 
107
209
 
108
210
  def _ensure_api_key() -> str:
109
- secrets_manager = GriptapeNodes.SecretsManager()
211
+ secrets_manager = griptape_nodes.SecretsManager()
110
212
  api_key = secrets_manager.get_secret("GT_CLOUD_API_KEY")
111
213
  if api_key is None:
112
214
  message = Panel(
@@ -127,79 +229,221 @@ def _ensure_api_key() -> str:
127
229
 
128
230
  def _build_static_dir() -> Path:
129
231
  """Build the static directory path based on the workspace configuration."""
130
- config_manager = GriptapeNodes.ConfigManager()
232
+ config_manager = griptape_nodes.ConfigManager()
131
233
  return Path(config_manager.workspace_path) / config_manager.merged_config["static_files_directory"]
132
234
 
133
235
 
134
- def _init_event_listeners() -> None:
135
- """Set up the Griptape EventBus EventListeners."""
136
- EventBus.add_event_listener(
137
- event_listener=EventListener(on_event=__process_node_event, event_types=[GriptapeNodeEvent])
138
- )
236
+ async def _process_incoming_messages(ws_connection: Any, main_loop: asyncio.AbstractEventLoop) -> None:
237
+ """Process incoming WebSocket requests from Nodes API."""
238
+ logger.info("Processing incoming WebSocket requests from WebSocket connection")
139
239
 
140
- EventBus.add_event_listener(
141
- event_listener=EventListener(
142
- on_event=__process_execution_node_event,
143
- event_types=[ExecutionGriptapeNodeEvent],
144
- )
145
- )
240
+ try:
241
+ async for message in ws_connection:
242
+ try:
243
+ data = json.loads(message)
244
+ await _process_api_event(data, main_loop)
245
+ except Exception:
246
+ logger.exception("Error processing event, skipping.")
247
+
248
+ except ConnectionClosed:
249
+ logger.info("WebSocket connection closed, will retry")
250
+ except asyncio.CancelledError:
251
+ # Clean shutdown when task is cancelled
252
+ logger.info("WebSocket listener shutdown complete")
253
+ raise
254
+ except Exception as e:
255
+ logger.error("Error in WebSocket connection. Retrying in 2 seconds... %s", e)
256
+ await asyncio.sleep(2.0)
257
+ raise
258
+ finally:
259
+ logger.info("WebSocket listener shutdown complete")
146
260
 
147
- EventBus.add_event_listener(
148
- event_listener=EventListener(
149
- on_event=__process_progress_event,
150
- event_types=[ProgressEvent],
151
- )
261
+
262
+ def _create_websocket_connection(api_key: str) -> Any:
263
+ """Create an async WebSocket connection to the Nodes API."""
264
+ endpoint = urljoin(
265
+ os.getenv("GRIPTAPE_NODES_API_BASE_URL", "https://api.nodes.griptape.ai").replace("http", "ws"),
266
+ "/ws/engines/events?version=v2",
152
267
  )
153
268
 
154
- EventBus.add_event_listener(
155
- event_listener=EventListener(
156
- on_event=__process_app_event, # pyright: ignore[reportArgumentType] TODO: https://github.com/griptape-ai/griptape-nodes/issues/868
157
- event_types=[AppEvent], # pyright: ignore[reportArgumentType] TODO: https://github.com/griptape-ai/griptape-nodes/issues/868
158
- )
269
+ return connect(
270
+ endpoint,
271
+ additional_headers={"Authorization": f"Bearer {api_key}"},
159
272
  )
160
273
 
161
274
 
162
- async def _alisten_for_api_requests(api_key: str) -> None:
163
- """Listen for events from the Nodes API and process them asynchronously."""
164
- global ws_connection_for_sending, event_loop # noqa: PLW0603
165
- event_loop = asyncio.get_running_loop() # Store the event loop reference
166
- logger.info("Listening for events from Nodes API via async WebSocket")
275
+ async def _process_api_event(event: dict, main_loop: asyncio.AbstractEventLoop) -> None:
276
+ """Process API events and add to async queue."""
277
+ payload = event.get("payload", {})
167
278
 
168
- # Auto reconnect https://websockets.readthedocs.io/en/stable/reference/asyncio/client.html#opening-a-connection
169
- connection_stream = _create_websocket_connection(api_key)
170
- initialized = False
171
- async for ws_connection in connection_stream:
172
- try:
173
- ws_connection_for_sending = ws_connection # Store for sending events
174
- ws_ready_event.set() # Signal that WebSocket is ready for sending
279
+ try:
280
+ payload["request"]
281
+ except KeyError:
282
+ msg = "Error: 'request' was expected but not found."
283
+ raise RuntimeError(msg) from None
175
284
 
176
- if not initialized:
177
- event_queue.put(AppEvent(payload=app_events.AppInitializationComplete()))
178
- initialized = True
285
+ try:
286
+ event_type = payload["event_type"]
287
+ if event_type != "EventRequest":
288
+ msg = "Error: 'event_type' was found on request, but did not match 'EventRequest' as expected."
289
+ raise RuntimeError(msg) from None
290
+ except KeyError:
291
+ msg = "Error: 'event_type' not found in request."
292
+ raise RuntimeError(msg) from None
293
+
294
+ # Now attempt to convert it into an EventRequest.
295
+ try:
296
+ request_event = deserialize_event(json_data=payload)
297
+ except Exception as e:
298
+ msg = f"Unable to convert request JSON into a valid EventRequest object. Error Message: '{e}'"
299
+ raise RuntimeError(msg) from None
300
+
301
+ if not isinstance(request_event, EventRequest):
302
+ msg = f"Deserialized event is not an EventRequest: {type(request_event)}"
303
+ raise TypeError(msg)
304
+
305
+ # Check if the event implements SkipTheLineMixin for priority processing
306
+ if isinstance(request_event.request, SkipTheLineMixin):
307
+ # Handle the event immediately without queuing
308
+ await _process_event_request(request_event)
309
+ else:
310
+ # Add the event to the main thread event queue for processing
311
+ griptape_nodes.EventManager().put_event_threadsafe(main_loop, request_event)
312
+
313
+
314
+ async def _send_outgoing_messages(ws_connection: Any) -> None:
315
+ """Send outgoing WebSocket requests from queue on background thread."""
316
+ logger.info("Starting outgoing WebSocket request sender")
317
+
318
+ try:
319
+ while True:
320
+ # Get message from outgoing queue
321
+ message = await ws_outgoing_queue.get()
322
+
323
+ try:
324
+ if isinstance(message, WebSocketMessage):
325
+ await _send_websocket_message(ws_connection, message.event_type, message.payload, message.topic)
326
+ elif isinstance(message, SubscribeCommand):
327
+ await _send_subscribe_command(ws_connection, message.topic)
328
+ elif isinstance(message, UnsubscribeCommand):
329
+ await _send_unsubscribe_command(ws_connection, message.topic)
330
+ else:
331
+ logger.warning("Unknown outgoing message type: %s", type(message))
332
+ except Exception as e:
333
+ logger.error("Error sending outgoing WebSocket request: %s", e)
334
+ finally:
335
+ ws_outgoing_queue.task_done()
336
+
337
+ except asyncio.CancelledError:
338
+ logger.info("Outbound request sender shutdown complete")
339
+ raise
340
+ except Exception as e:
341
+ logger.error("Fatal error in outgoing request sender: %s", e)
342
+ raise
343
+
344
+
345
+ async def _send_websocket_message(ws_connection: Any, event_type: str, payload: str, topic: str | None) -> None:
346
+ """Send a message via WebSocket."""
347
+ try:
348
+ if topic is None:
349
+ topic = determine_response_topic()
350
+
351
+ body = {"type": event_type, "payload": json.loads(payload), "topic": topic}
352
+ await ws_connection.send(json.dumps(body))
353
+ except WebSocketException as e:
354
+ logger.error("Error sending WebSocket message: %s", e)
355
+ except Exception as e:
356
+ logger.error("Unexpected error sending WebSocket message: %s", e)
357
+
358
+
359
+ async def _send_subscribe_command(ws_connection: Any, topic: str) -> None:
360
+ """Send subscribe command via WebSocket."""
361
+ try:
362
+ body = {"type": "subscribe", "topic": topic, "payload": {}}
363
+ await ws_connection.send(json.dumps(body))
364
+ logger.info("Subscribed to topic: %s", topic)
365
+ except WebSocketException as e:
366
+ logger.error("Error subscribing to topic %s: %s", topic, e)
367
+ except Exception as e:
368
+ logger.error("Unexpected error subscribing to topic %s: %s", topic, e)
369
+
370
+
371
+ async def _send_unsubscribe_command(ws_connection: Any, topic: str) -> None:
372
+ """Send unsubscribe command via WebSocket."""
373
+ try:
374
+ body = {"type": "unsubscribe", "topic": topic, "payload": {}}
375
+ await ws_connection.send(json.dumps(body))
376
+ logger.info("Unsubscribed from topic: %s", topic)
377
+ except WebSocketException as e:
378
+ logger.error("Error unsubscribing from topic %s: %s", topic, e)
379
+ except Exception as e:
380
+ logger.error("Unexpected error unsubscribing from topic %s: %s", topic, e)
381
+
382
+
383
+ async def _process_event_queue() -> None:
384
+ """Process events concurrently - runs on main thread."""
385
+ logger.info("Starting event queue processor on main thread")
386
+ background_tasks = set()
179
387
 
180
- event_queue.put(AppEvent(payload=app_events.AppConnectionEstablished()))
388
+ def _handle_task_result(task: asyncio.Task) -> None:
389
+ background_tasks.discard(task)
390
+ if task.exception() and not task.cancelled():
391
+ logger.exception("Background task failed", exc_info=task.exception())
181
392
 
182
- async for message in ws_connection:
183
- try:
184
- data = json.loads(message)
393
+ try:
394
+ event_queue = griptape_nodes.EventManager().event_queue
395
+ while True:
396
+ event = await event_queue.get()
397
+
398
+ async with REQUEST_SEMAPHORE:
399
+ if isinstance(event, EventRequest):
400
+ task = asyncio.create_task(_process_event_request(event))
401
+ elif isinstance(event, AppEvent):
402
+ task = asyncio.create_task(_process_app_event(event))
403
+ elif isinstance(event, GriptapeNodeEvent):
404
+ task = asyncio.create_task(_process_node_event(event))
405
+ elif isinstance(event, ExecutionGriptapeNodeEvent):
406
+ task = asyncio.create_task(_process_execution_node_event(event))
407
+ elif isinstance(event, ProgressEvent):
408
+ task = asyncio.create_task(_process_progress_event(event))
409
+ else:
410
+ logger.warning("Unknown event type: %s", type(event))
411
+ event_queue.task_done()
412
+ continue
413
+
414
+ background_tasks.add(task)
415
+ task.add_done_callback(_handle_task_result)
416
+ event_queue.task_done()
417
+ except asyncio.CancelledError:
418
+ logger.info("Event queue processor shutdown complete")
419
+ raise
420
+
421
+
422
+ async def _process_event_request(event: EventRequest) -> None:
423
+ """Handle request and emit success/failure events based on result."""
424
+ result_event = await griptape_nodes.EventManager().ahandle_request(
425
+ event.request,
426
+ result_context={"response_topic": event.response_topic, "request_id": event.request_id},
427
+ )
428
+
429
+ if result_event.result.succeeded():
430
+ dest_socket = "success_result"
431
+ else:
432
+ dest_socket = "failure_result"
433
+
434
+ await _send_message(dest_socket, result_event.json(), topic=result_event.response_topic)
185
435
 
186
- _process_api_event(data, event_queue)
187
- except Exception:
188
- logger.exception("Error processing event, skipping.")
189
- except ConnectionClosed:
190
- continue
191
- except Exception as e:
192
- logger.error("Error while listening for events. Retrying in 2 seconds... %s", e)
193
- await asyncio.sleep(2)
194
436
 
437
+ async def _process_app_event(event: AppEvent) -> None:
438
+ """Process AppEvents and send them to the API (async version)."""
439
+ # Let Griptape Nodes broadcast it.
440
+ await griptape_nodes.broadcast_app_event(event.payload)
195
441
 
196
- def _listen_for_api_events(api_key: str) -> None:
197
- """Run the async WebSocket listener in an event loop."""
198
- asyncio.run(_alisten_for_api_requests(api_key))
442
+ await _send_message("app_event", event.json())
199
443
 
200
444
 
201
- def __process_node_event(event: GriptapeNodeEvent) -> None:
202
- """Process GriptapeNodeEvents and send them to the API."""
445
+ async def _process_node_event(event: GriptapeNodeEvent) -> None:
446
+ """Process GriptapeNodeEvents and send them to the API (async version)."""
203
447
  # Emit the result back to the GUI
204
448
  result_event = event.wrapped_event
205
449
  if isinstance(result_event, EventResultSuccess):
@@ -210,32 +454,16 @@ def __process_node_event(event: GriptapeNodeEvent) -> None:
210
454
  msg = f"Unknown/unsupported result event type encountered: '{type(result_event)}'."
211
455
  raise TypeError(msg) from None
212
456
 
213
- __schedule_async_task(__emit_message(dest_socket, result_event.json(), topic=result_event.response_topic))
457
+ await _send_message(dest_socket, result_event.json(), topic=result_event.response_topic)
214
458
 
215
459
 
216
- def __process_execution_node_event(event: ExecutionGriptapeNodeEvent) -> None:
217
- """Process ExecutionGriptapeNodeEvents and send them to the API."""
218
- result_event = event.wrapped_event
219
- if type(result_event.payload).__name__ == "NodeStartProcessEvent":
220
- GriptapeNodes.EventManager().current_active_node = result_event.payload.node_name
221
-
222
- if type(result_event.payload).__name__ == "ResumeNodeProcessingEvent":
223
- node_name = result_event.payload.node_name
224
- logger.info("Resuming Node '%s'", node_name)
225
- flow_name = GriptapeNodes.NodeManager().get_node_parent_flow_by_name(node_name)
226
- request = EventRequest(request=execution_events.SingleExecutionStepRequest(flow_name=flow_name))
227
- event_queue.put(request)
228
-
229
- if type(result_event.payload).__name__ == "NodeFinishProcessEvent":
230
- if result_event.payload.node_name != GriptapeNodes.EventManager().current_active_node:
231
- msg = "Node start and finish do not match."
232
- raise KeyError(msg) from None
233
- GriptapeNodes.EventManager().current_active_node = None
234
- __schedule_async_task(__emit_message("execution_event", result_event.json()))
235
-
236
-
237
- def __process_progress_event(gt_event: ProgressEvent) -> None:
238
- """Process Griptape framework events and send them to the API."""
460
+ async def _process_execution_node_event(event: ExecutionGriptapeNodeEvent) -> None:
461
+ """Process ExecutionGriptapeNodeEvents and send them to the API (async version)."""
462
+ await _send_message("execution_event", event.wrapped_event.json())
463
+
464
+
465
+ async def _process_progress_event(gt_event: ProgressEvent) -> None:
466
+ """Process Griptape framework events and send them to the API (async version)."""
239
467
  node_name = gt_event.node_name
240
468
  if node_name:
241
469
  value = gt_event.value
@@ -243,82 +471,53 @@ def __process_progress_event(gt_event: ProgressEvent) -> None:
243
471
  node_name=node_name, parameter_name=gt_event.parameter_name, type=type(gt_event).__name__, value=value
244
472
  )
245
473
  event_to_emit = ExecutionEvent(payload=payload)
246
- __schedule_async_task(__emit_message("execution_event", event_to_emit.json()))
474
+ await _send_message("execution_event", event_to_emit.json())
247
475
 
248
476
 
249
- def __process_app_event(event: AppEvent) -> None:
250
- """Process AppEvents and send them to the API."""
251
- # Let Griptape Nodes broadcast it.
252
- GriptapeNodes.broadcast_app_event(event.payload)
477
+ async def _send_message(event_type: str, payload: str, topic: str | None = None) -> None:
478
+ """Queue a message to be sent via WebSocket using run_coroutine_threadsafe."""
479
+ # Wait for websocket event loop to be ready
480
+ websocket_event_loop_ready.wait()
253
481
 
254
- __schedule_async_task(__emit_message("app_event", event.json()))
482
+ # Use run_coroutine_threadsafe to put message into WebSocket background thread queue
483
+ if websocket_event_loop is None:
484
+ logger.error("WebSocket event loop not available for message")
485
+ return
255
486
 
487
+ # Determine topic based on session_id and engine_id in the payload
488
+ if topic is None:
489
+ topic = determine_response_topic()
256
490
 
257
- def _process_event_queue() -> None:
258
- """Listen for events in the event queue and process them.
491
+ message = WebSocketMessage(event_type, payload, topic)
259
492
 
260
- Event queue will be populated by background threads listening for events from the Nodes API.
261
- """
262
- # Wait for WebSocket connection to be established before processing events
263
- timed_out = ws_ready_event.wait(timeout=15)
264
- if not timed_out:
265
- console.print(
266
- "[red] The connection to the websocket timed out. Please check your internet connection or the status of Griptape Nodes API.[/red]"
267
- )
268
- sys.exit(1)
269
- while True:
270
- event = event_queue.get(block=True)
271
- if isinstance(event, EventRequest):
272
- request_payload = event.request
273
- GriptapeNodes.handle_request(
274
- request_payload, response_topic=event.response_topic, request_id=event.request_id
275
- )
276
- elif isinstance(event, AppEvent):
277
- __process_app_event(event)
278
- else:
279
- logger.warning("Unknown event type encountered: '%s'.", type(event))
493
+ asyncio.run_coroutine_threadsafe(ws_outgoing_queue.put(message), websocket_event_loop)
280
494
 
281
- event_queue.task_done()
282
495
 
496
+ async def subscribe_to_topic(topic: str) -> None:
497
+ """Queue a subscribe command for WebSocket using run_coroutine_threadsafe."""
498
+ # Wait for websocket event loop to be ready
499
+ websocket_event_loop_ready.wait()
283
500
 
284
- def _create_websocket_connection(api_key: str) -> Any:
285
- """Create an async WebSocket connection to the Nodes API."""
286
- endpoint = urljoin(
287
- os.getenv("GRIPTAPE_NODES_API_BASE_URL", "https://api.nodes.griptape.ai").replace("http", "ws"),
288
- "/ws/engines/events?version=v2",
289
- )
501
+ if websocket_event_loop is None:
502
+ logger.error("WebSocket event loop not available for subscribe")
503
+ return
290
504
 
291
- return connect(
292
- endpoint,
293
- additional_headers={"Authorization": f"Bearer {api_key}"},
294
- )
505
+ asyncio.run_coroutine_threadsafe(ws_outgoing_queue.put(SubscribeCommand(topic)), websocket_event_loop)
295
506
 
296
507
 
297
- async def __emit_message(event_type: str, payload: str, topic: str | None = None) -> None:
298
- """Send a message via WebSocket asynchronously."""
299
- global ws_connection_for_sending # noqa: PLW0602
300
- if ws_connection_for_sending is None:
301
- logger.warning("WebSocket connection not available for sending message")
508
+ async def unsubscribe_from_topic(topic: str) -> None:
509
+ """Queue an unsubscribe command for WebSocket using run_coroutine_threadsafe."""
510
+ if websocket_event_loop is None:
511
+ logger.error("WebSocket event loop not available for unsubscribe")
302
512
  return
303
513
 
304
- try:
305
- # Determine topic based on session_id and engine_id in the payload
306
- if topic is None:
307
- topic = _determine_response_topic()
308
-
309
- body = {"type": event_type, "payload": json.loads(payload), "topic": topic}
310
-
311
- await ws_connection_for_sending.send(json.dumps(body))
312
- except WebSocketException as e:
313
- logger.error("Error sending event to Nodes API: %s", e)
314
- except Exception as e:
315
- logger.error("Unexpected error while sending event to Nodes API: %s", e)
514
+ asyncio.run_coroutine_threadsafe(ws_outgoing_queue.put(UnsubscribeCommand(topic)), websocket_event_loop)
316
515
 
317
516
 
318
- def _determine_response_topic() -> str | None:
517
+ def determine_response_topic() -> str | None:
319
518
  """Determine the response topic based on session_id and engine_id in the payload."""
320
- engine_id = GriptapeNodes.get_engine_id()
321
- session_id = GriptapeNodes.get_session_id()
519
+ engine_id = griptape_nodes.get_engine_id()
520
+ session_id = griptape_nodes.get_session_id()
322
521
 
323
522
  # Normal topic determination logic
324
523
  # Check for session_id first (highest priority)
@@ -333,10 +532,10 @@ def _determine_response_topic() -> str | None:
333
532
  return "response"
334
533
 
335
534
 
336
- def _determine_request_topic() -> str | None:
535
+ def determine_request_topic() -> str | None:
337
536
  """Determine the request topic based on session_id and engine_id in the payload."""
338
- engine_id = GriptapeNodes.get_engine_id()
339
- session_id = GriptapeNodes.get_session_id()
537
+ engine_id = griptape_nodes.get_engine_id()
538
+ session_id = griptape_nodes.get_session_id()
340
539
 
341
540
  # Normal topic determination logic
342
541
  # Check for session_id first (highest priority)
@@ -349,117 +548,3 @@ def _determine_request_topic() -> str | None:
349
548
 
350
549
  # Default to generic request topic
351
550
  return "request"
352
-
353
-
354
- def subscribe_to_topic(topic: str) -> None:
355
- """Subscribe to a specific topic in the message bus."""
356
- __schedule_async_task(_asubscribe_to_topic(topic))
357
-
358
-
359
- def unsubscribe_from_topic(topic: str) -> None:
360
- """Unsubscribe from a specific topic in the message bus."""
361
- __schedule_async_task(_aunsubscribe_from_topic(topic))
362
-
363
-
364
- async def _asubscribe_to_topic(topic: str) -> None:
365
- """Subscribe to a specific topic in the message bus."""
366
- if ws_connection_for_sending is None:
367
- logger.warning("WebSocket connection not available for subscribing to topic")
368
- return
369
-
370
- try:
371
- body = {"type": "subscribe", "topic": topic, "payload": {}}
372
- await ws_connection_for_sending.send(json.dumps(body))
373
- logger.info("Subscribed to topic: %s", topic)
374
- except WebSocketException as e:
375
- logger.error("Error subscribing to topic %s: %s", topic, e)
376
- except Exception as e:
377
- logger.error("Unexpected error while subscribing to topic %s: %s", topic, e)
378
-
379
-
380
- async def _aunsubscribe_from_topic(topic: str) -> None:
381
- """Unsubscribe from a specific topic in the message bus."""
382
- if ws_connection_for_sending is None:
383
- logger.warning("WebSocket connection not available for unsubscribing from topic")
384
- return
385
-
386
- try:
387
- body = {"type": "unsubscribe", "topic": topic, "payload": {}}
388
- await ws_connection_for_sending.send(json.dumps(body))
389
- logger.info("Unsubscribed from topic: %s", topic)
390
- except WebSocketException as e:
391
- logger.error("Error unsubscribing from topic %s: %s", topic, e)
392
- except Exception as e:
393
- logger.error("Unexpected error while unsubscribing from topic %s: %s", topic, e)
394
-
395
-
396
- def __schedule_async_task(coro: Any) -> None:
397
- """Schedule an async coroutine to run in the event loop from a sync context."""
398
- if event_loop and event_loop.is_running():
399
- asyncio.run_coroutine_threadsafe(coro, event_loop)
400
- else:
401
- logger.warning("Event loop not available for scheduling async task")
402
-
403
-
404
- def _process_api_event(event: dict, event_queue: Queue) -> None:
405
- """Process API events and send them to the event queue."""
406
- payload = event.get("payload", {})
407
-
408
- try:
409
- payload["request"]
410
- except KeyError:
411
- msg = "Error: 'request' was expected but not found."
412
- raise RuntimeError(msg) from None
413
-
414
- try:
415
- event_type = payload["event_type"]
416
- if event_type != "EventRequest":
417
- msg = "Error: 'event_type' was found on request, but did not match 'EventRequest' as expected."
418
- raise RuntimeError(msg) from None
419
- except KeyError:
420
- msg = "Error: 'event_type' not found in request."
421
- raise RuntimeError(msg) from None
422
-
423
- # Now attempt to convert it into an EventRequest.
424
- try:
425
- request_event = deserialize_event(json_data=payload)
426
- if not isinstance(request_event, EventRequest):
427
- msg = f"Deserialized event is not an EventRequest: {type(request_event)}"
428
- raise TypeError(msg) # noqa: TRY301
429
- except Exception as e:
430
- msg = f"Unable to convert request JSON into a valid EventRequest object. Error Message: '{e}'"
431
- raise RuntimeError(msg) from None
432
-
433
- # Check if the event implements SkipTheLineMixin for priority processing
434
- if isinstance(request_event.request, SkipTheLineMixin):
435
- # Handle the event immediately without queuing
436
- # The request is guaranteed to be a RequestPayload since it passed earlier validation
437
- result_payload = GriptapeNodes.handle_request(
438
- cast("RequestPayload", request_event.request),
439
- response_topic=request_event.response_topic,
440
- request_id=request_event.request_id,
441
- )
442
-
443
- # Create the result event and emit response immediately
444
- if result_payload.succeeded():
445
- result_event = EventResultSuccess(
446
- request=cast("RequestPayload", request_event.request),
447
- request_id=request_event.request_id,
448
- result=result_payload,
449
- response_topic=request_event.response_topic,
450
- )
451
- dest_socket = "success_result"
452
- else:
453
- result_event = EventResultFailure(
454
- request=cast("RequestPayload", request_event.request),
455
- request_id=request_event.request_id,
456
- result=result_payload,
457
- response_topic=request_event.response_topic,
458
- )
459
- dest_socket = "failure_result"
460
-
461
- # Emit the response immediately
462
- __schedule_async_task(__emit_message(dest_socket, result_event.json(), topic=result_event.response_topic))
463
- else:
464
- # Add the event to the queue for normal processing
465
- event_queue.put(request_event)