griptape-nodes 0.51.1__py3-none-any.whl → 0.52.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 +5 -4
  2. griptape_nodes/app/api.py +27 -24
  3. griptape_nodes/app/app.py +243 -221
  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 +28 -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 +81 -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.1.dist-info → griptape_nodes-0.52.0.dist-info}/METADATA +2 -3
  43. {griptape_nodes-0.51.1.dist-info → griptape_nodes-0.52.0.dist-info}/RECORD +45 -42
  44. {griptape_nodes-0.51.1.dist-info → griptape_nodes-0.52.0.dist-info}/WHEEL +1 -1
  45. griptape_nodes/bootstrap/workflow_executors/subprocess_workflow_executor.py +0 -90
  46. {griptape_nodes-0.51.1.dist-info → griptape_nodes-0.52.0.dist-info}/entry_points.txt +0 -0
griptape_nodes/app/app.py CHANGED
@@ -1,21 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
5
+ import contextvars
4
6
  import json
5
7
  import logging
6
8
  import os
7
- import signal
8
9
  import sys
9
- import threading
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,6 @@ 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
27
21
  from griptape_nodes.retained_mode.events import app_events, execution_events
28
22
 
29
23
  # This import is necessary to register all events, even if not technically used
@@ -36,29 +30,31 @@ from griptape_nodes.retained_mode.events.base_events import (
36
30
  ExecutionGriptapeNodeEvent,
37
31
  GriptapeNodeEvent,
38
32
  ProgressEvent,
39
- RequestPayload,
40
33
  SkipTheLineMixin,
41
34
  deserialize_event,
42
35
  )
43
36
  from griptape_nodes.retained_mode.events.logger_events import LogHandlerEvent
44
37
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
45
38
 
46
- from .api import start_api
47
-
48
- # This is a global event queue that will be used to pass events between threads
49
- event_queue = Queue()
50
-
51
- # Global WebSocket connection for sending events
52
- ws_connection_for_sending = None
53
- event_loop = None
39
+ # Context variable for WebSocket connection - avoids global state
40
+ ws_connection_context: contextvars.ContextVar[Any | None] = contextvars.ContextVar("ws_connection", default=None)
54
41
 
55
42
  # Event to signal when WebSocket connection is ready
56
- ws_ready_event = threading.Event()
43
+ ws_ready_event = asyncio.Event()
57
44
 
58
45
 
59
46
  # Whether to enable the static server
60
47
  STATIC_SERVER_ENABLED = os.getenv("STATIC_SERVER_ENABLED", "true").lower() == "true"
61
48
 
49
+ # Semaphore to limit concurrent requests
50
+ REQUEST_SEMAPHORE = asyncio.Semaphore(100)
51
+
52
+
53
+ # Important to bootstrap singleton here so that we don't
54
+ # get any weird circular import issues from the EventLogHandler
55
+ # initializing it from a log during it's own initialization.
56
+ griptape_nodes: GriptapeNodes = GriptapeNodes()
57
+
62
58
 
63
59
  class EventLogHandler(logging.Handler):
64
60
  """Custom logging handler that emits log messages as AppEvents.
@@ -67,11 +63,10 @@ class EventLogHandler(logging.Handler):
67
63
  """
68
64
 
69
65
  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
- )
66
+ log_event = AppEvent(
67
+ payload=LogHandlerEvent(message=record.getMessage(), levelname=record.levelname, created=record.created)
74
68
  )
69
+ griptape_nodes.EventManager().put_event(log_event)
75
70
 
76
71
 
77
72
  # 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 +82,79 @@ console = Console()
87
82
 
88
83
 
89
84
  def start_app() -> None:
90
- """Main entry point for the Griptape Nodes app.
85
+ """Legacy sync entry point - runs async app."""
86
+ try:
87
+ asyncio.run(astart_app())
88
+ except KeyboardInterrupt:
89
+ logger.info("Application stopped by user")
90
+ except Exception as e:
91
+ logger.error("Application error: %s", e)
91
92
 
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
93
 
94
+ async def astart_app() -> None:
95
+ """New async app entry point."""
99
96
  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()
97
+
98
+ griptape_nodes.EventManager().initialize_queue()
99
+
100
+ # Create shared context for all tasks to inherit WebSocket connection
101
+ shared_context = contextvars.copy_context()
102
+
103
+ try:
104
+ # We need to run the servers in a separate thread otherwise
105
+ # blocking requests to them in the main thread would deadlock the event loop.
106
+ server_tasks = []
107
+
108
+ # Start MCP server in thread
109
+ server_tasks.append(asyncio.to_thread(_run_mcp_server_sync, api_key))
110
+
111
+ # Start static server in thread if enabled
112
+ if STATIC_SERVER_ENABLED:
113
+ static_dir = _build_static_dir()
114
+ server_tasks.append(asyncio.to_thread(_run_static_server_sync, static_dir))
115
+
116
+ # Run main event loop tasks
117
+ main_tasks = [
118
+ _listen_for_api_requests(api_key),
119
+ _process_event_queue(),
120
+ ]
121
+
122
+ # Combine server tasks and main tasks
123
+ all_tasks = server_tasks + main_tasks
124
+
125
+ async with asyncio.TaskGroup() as tg:
126
+ for task in all_tasks:
127
+ # Context is supposed to be copied automatically, but it isn't working for some reason so we do it manually here
128
+ tg.create_task(task, context=shared_context)
129
+ except Exception as e:
130
+ logger.error("Application startup failed: %s", e)
131
+ raise
132
+
133
+
134
+ def _run_mcp_server_sync(api_key: str) -> None:
135
+ """Run MCP server in a separate thread."""
136
+ try:
137
+ from griptape_nodes.mcp_server.server import main_sync
138
+
139
+ main_sync(api_key)
140
+ except Exception as e:
141
+ logger.error("MCP server thread error: %s", e)
142
+ raise
143
+
144
+
145
+ def _run_static_server_sync(static_dir: Path) -> None:
146
+ """Run static server in a separate thread."""
147
+ try:
148
+ from .api import start_api
149
+
150
+ start_api(static_dir)
151
+ except Exception as e:
152
+ logger.error("Static server thread error: %s", e)
153
+ raise
106
154
 
107
155
 
108
156
  def _ensure_api_key() -> str:
109
- secrets_manager = GriptapeNodes.SecretsManager()
157
+ secrets_manager = griptape_nodes.SecretsManager()
110
158
  api_key = secrets_manager.get_secret("GT_CLOUD_API_KEY")
111
159
  if api_key is None:
112
160
  message = Panel(
@@ -127,79 +175,145 @@ def _ensure_api_key() -> str:
127
175
 
128
176
  def _build_static_dir() -> Path:
129
177
  """Build the static directory path based on the workspace configuration."""
130
- config_manager = GriptapeNodes.ConfigManager()
178
+ config_manager = griptape_nodes.ConfigManager()
131
179
  return Path(config_manager.workspace_path) / config_manager.merged_config["static_files_directory"]
132
180
 
133
181
 
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
- )
182
+ async def _listen_for_api_requests(api_key: str) -> None:
183
+ """Listen for events and add to async queue."""
184
+ logger.info("Listening for events from Nodes API via async WebSocket")
139
185
 
140
- EventBus.add_event_listener(
141
- event_listener=EventListener(
142
- on_event=__process_execution_node_event,
143
- event_types=[ExecutionGriptapeNodeEvent],
144
- )
145
- )
186
+ connection_stream = _create_websocket_connection(api_key)
187
+ initialized = False
146
188
 
147
- EventBus.add_event_listener(
148
- event_listener=EventListener(
149
- on_event=__process_progress_event,
150
- event_types=[ProgressEvent],
151
- )
152
- )
189
+ try:
190
+ async for ws_connection in connection_stream:
191
+ await _handle_websocket_connection(ws_connection, initialized=initialized)
192
+ initialized = True
193
+
194
+ except asyncio.CancelledError:
195
+ # Clean shutdown when task is cancelled
196
+ logger.info("WebSocket listener shutdown complete")
197
+ raise
198
+ except Exception as e:
199
+ logger.error("Fatal error in WebSocket listener: %s", e)
200
+ raise
201
+ finally:
202
+ await _cleanup_websocket_connection()
153
203
 
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
- )
159
- )
160
204
 
205
+ async def _handle_websocket_connection(ws_connection: Any, *, initialized: bool) -> None:
206
+ """Handle a single WebSocket connection."""
207
+ try:
208
+ ws_connection_context.set(ws_connection)
209
+ ws_ready_event.set()
161
210
 
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")
211
+ if not initialized:
212
+ await griptape_nodes.EventManager().aput_event(AppEvent(payload=app_events.AppInitializationComplete()))
167
213
 
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
214
+ await griptape_nodes.EventManager().aput_event(AppEvent(payload=app_events.AppConnectionEstablished()))
175
215
 
176
- if not initialized:
177
- event_queue.put(AppEvent(payload=app_events.AppInitializationComplete()))
178
- initialized = True
216
+ async for message in ws_connection:
217
+ try:
218
+ data = json.loads(message)
219
+ await _process_api_event(data)
220
+ except Exception:
221
+ logger.exception("Error processing event, skipping.")
179
222
 
180
- event_queue.put(AppEvent(payload=app_events.AppConnectionEstablished()))
223
+ except ConnectionClosed:
224
+ logger.info("WebSocket connection closed, will retry")
225
+ except Exception as e:
226
+ logger.error("Error in WebSocket connection. Retrying in 2 seconds... %s", e)
227
+ await asyncio.sleep(2.0)
228
+ finally:
229
+ ws_connection_context.set(None)
230
+ ws_ready_event.clear()
181
231
 
182
- async for message in ws_connection:
183
- try:
184
- data = json.loads(message)
185
232
 
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)
233
+ async def _cleanup_websocket_connection() -> None:
234
+ """Clean up WebSocket connection on shutdown."""
235
+ ws_connection = ws_connection_context.get()
236
+ if ws_connection:
237
+ with contextlib.suppress(Exception):
238
+ await ws_connection.close()
239
+ logger.info("WebSocket listener shutdown complete")
194
240
 
195
241
 
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))
242
+ async def _process_event_queue() -> None:
243
+ """Process events concurrently - all events can run simultaneously."""
244
+ # Wait for WebSocket connection (convert to async)
245
+ await _await_websocket_ready()
246
+ background_tasks = set()
199
247
 
248
+ def _handle_task_result(task: asyncio.Task) -> None:
249
+ background_tasks.discard(task)
250
+ if task.exception() and not task.cancelled():
251
+ logger.exception("Background task failed", exc_info=task.exception())
200
252
 
201
- def __process_node_event(event: GriptapeNodeEvent) -> None:
202
- """Process GriptapeNodeEvents and send them to the API."""
253
+ try:
254
+ event_queue = griptape_nodes.EventManager().event_queue
255
+ while True:
256
+ event = await event_queue.get()
257
+
258
+ async with REQUEST_SEMAPHORE:
259
+ if isinstance(event, EventRequest):
260
+ task = asyncio.create_task(_process_event_request(event))
261
+ elif isinstance(event, AppEvent):
262
+ task = asyncio.create_task(_process_app_event(event))
263
+ elif isinstance(event, GriptapeNodeEvent):
264
+ task = asyncio.create_task(_process_node_event(event))
265
+ elif isinstance(event, ExecutionGriptapeNodeEvent):
266
+ task = asyncio.create_task(_process_execution_node_event(event))
267
+ elif isinstance(event, ProgressEvent):
268
+ task = asyncio.create_task(_process_progress_event(event))
269
+ else:
270
+ logger.warning("Unknown event type: %s", type(event))
271
+ event_queue.task_done()
272
+ continue
273
+
274
+ background_tasks.add(task)
275
+ task.add_done_callback(_handle_task_result)
276
+ event_queue.task_done()
277
+ except asyncio.CancelledError:
278
+ logger.info("Event queue processor shutdown complete")
279
+ raise
280
+
281
+
282
+ async def _process_event_request(event: EventRequest) -> None:
283
+ """Handle request and emit success/failure events based on result."""
284
+ result_event = await griptape_nodes.EventManager().ahandle_request(
285
+ event.request,
286
+ result_context={"response_topic": event.response_topic, "request_id": event.request_id},
287
+ )
288
+
289
+ if result_event.result.succeeded():
290
+ dest_socket = "success_result"
291
+ else:
292
+ dest_socket = "failure_result"
293
+
294
+ await __emit_message(dest_socket, result_event.json(), topic=result_event.response_topic)
295
+
296
+
297
+ async def _await_websocket_ready() -> None:
298
+ """Wait for WebSocket connection to be ready using event coordination."""
299
+ websocket_timeout = 15
300
+ try:
301
+ await asyncio.wait_for(ws_ready_event.wait(), timeout=websocket_timeout)
302
+ except TimeoutError:
303
+ console.print("[red]WebSocket connection timeout[/red]")
304
+ raise
305
+
306
+
307
+ async def _process_app_event(event: AppEvent) -> None:
308
+ """Process AppEvents and send them to the API (async version)."""
309
+ # Let Griptape Nodes broadcast it.
310
+ await griptape_nodes.broadcast_app_event(event.payload)
311
+
312
+ await __emit_message("app_event", event.json())
313
+
314
+
315
+ async def _process_node_event(event: GriptapeNodeEvent) -> None:
316
+ """Process GriptapeNodeEvents and send them to the API (async version)."""
203
317
  # Emit the result back to the GUI
204
318
  result_event = event.wrapped_event
205
319
  if isinstance(result_event, EventResultSuccess):
@@ -210,32 +324,16 @@ def __process_node_event(event: GriptapeNodeEvent) -> None:
210
324
  msg = f"Unknown/unsupported result event type encountered: '{type(result_event)}'."
211
325
  raise TypeError(msg) from None
212
326
 
213
- __schedule_async_task(__emit_message(dest_socket, result_event.json(), topic=result_event.response_topic))
327
+ await __emit_message(dest_socket, result_event.json(), topic=result_event.response_topic)
214
328
 
215
329
 
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."""
330
+ async def _process_execution_node_event(event: ExecutionGriptapeNodeEvent) -> None:
331
+ """Process ExecutionGriptapeNodeEvents and send them to the API (async version)."""
332
+ await __emit_message("execution_event", event.wrapped_event.json())
333
+
334
+
335
+ async def _process_progress_event(gt_event: ProgressEvent) -> None:
336
+ """Process Griptape framework events and send them to the API (async version)."""
239
337
  node_name = gt_event.node_name
240
338
  if node_name:
241
339
  value = gt_event.value
@@ -243,42 +341,7 @@ def __process_progress_event(gt_event: ProgressEvent) -> None:
243
341
  node_name=node_name, parameter_name=gt_event.parameter_name, type=type(gt_event).__name__, value=value
244
342
  )
245
343
  event_to_emit = ExecutionEvent(payload=payload)
246
- __schedule_async_task(__emit_message("execution_event", event_to_emit.json()))
247
-
248
-
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)
253
-
254
- __schedule_async_task(__emit_message("app_event", event.json()))
255
-
256
-
257
- def _process_event_queue() -> None:
258
- """Listen for events in the event queue and process them.
259
-
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))
280
-
281
- event_queue.task_done()
344
+ await __emit_message("execution_event", event_to_emit.json())
282
345
 
283
346
 
284
347
  def _create_websocket_connection(api_key: str) -> Any:
@@ -296,29 +359,29 @@ def _create_websocket_connection(api_key: str) -> Any:
296
359
 
297
360
  async def __emit_message(event_type: str, payload: str, topic: str | None = None) -> None:
298
361
  """Send a message via WebSocket asynchronously."""
299
- global ws_connection_for_sending # noqa: PLW0602
300
- if ws_connection_for_sending is None:
362
+ ws_connection = ws_connection_context.get()
363
+ if ws_connection is None:
301
364
  logger.warning("WebSocket connection not available for sending message")
302
365
  return
303
366
 
304
367
  try:
305
368
  # Determine topic based on session_id and engine_id in the payload
306
369
  if topic is None:
307
- topic = _determine_response_topic()
370
+ topic = determine_response_topic()
308
371
 
309
372
  body = {"type": event_type, "payload": json.loads(payload), "topic": topic}
310
373
 
311
- await ws_connection_for_sending.send(json.dumps(body))
374
+ await ws_connection.send(json.dumps(body))
312
375
  except WebSocketException as e:
313
376
  logger.error("Error sending event to Nodes API: %s", e)
314
377
  except Exception as e:
315
378
  logger.error("Unexpected error while sending event to Nodes API: %s", e)
316
379
 
317
380
 
318
- def _determine_response_topic() -> str | None:
381
+ def determine_response_topic() -> str | None:
319
382
  """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()
383
+ engine_id = griptape_nodes.get_engine_id()
384
+ session_id = griptape_nodes.get_session_id()
322
385
 
323
386
  # Normal topic determination logic
324
387
  # Check for session_id first (highest priority)
@@ -333,10 +396,10 @@ def _determine_response_topic() -> str | None:
333
396
  return "response"
334
397
 
335
398
 
336
- def _determine_request_topic() -> str | None:
399
+ def determine_request_topic() -> str | None:
337
400
  """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()
401
+ engine_id = griptape_nodes.get_engine_id()
402
+ session_id = griptape_nodes.get_session_id()
340
403
 
341
404
  # Normal topic determination logic
342
405
  # Check for session_id first (highest priority)
@@ -351,25 +414,16 @@ def _determine_request_topic() -> str | None:
351
414
  return "request"
352
415
 
353
416
 
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:
417
+ async def subscribe_to_topic(topic: str) -> None:
365
418
  """Subscribe to a specific topic in the message bus."""
366
- if ws_connection_for_sending is None:
419
+ ws_connection = ws_connection_context.get()
420
+ if ws_connection is None:
367
421
  logger.warning("WebSocket connection not available for subscribing to topic")
368
422
  return
369
423
 
370
424
  try:
371
425
  body = {"type": "subscribe", "topic": topic, "payload": {}}
372
- await ws_connection_for_sending.send(json.dumps(body))
426
+ await ws_connection.send(json.dumps(body))
373
427
  logger.info("Subscribed to topic: %s", topic)
374
428
  except WebSocketException as e:
375
429
  logger.error("Error subscribing to topic %s: %s", topic, e)
@@ -377,15 +431,16 @@ async def _asubscribe_to_topic(topic: str) -> None:
377
431
  logger.error("Unexpected error while subscribing to topic %s: %s", topic, e)
378
432
 
379
433
 
380
- async def _aunsubscribe_from_topic(topic: str) -> None:
434
+ async def unsubscribe_from_topic(topic: str) -> None:
381
435
  """Unsubscribe from a specific topic in the message bus."""
382
- if ws_connection_for_sending is None:
436
+ ws_connection = ws_connection_context.get()
437
+ if ws_connection is None:
383
438
  logger.warning("WebSocket connection not available for unsubscribing from topic")
384
439
  return
385
440
 
386
441
  try:
387
442
  body = {"type": "unsubscribe", "topic": topic, "payload": {}}
388
- await ws_connection_for_sending.send(json.dumps(body))
443
+ await ws_connection.send(json.dumps(body))
389
444
  logger.info("Unsubscribed from topic: %s", topic)
390
445
  except WebSocketException as e:
391
446
  logger.error("Error unsubscribing from topic %s: %s", topic, e)
@@ -393,16 +448,8 @@ async def _aunsubscribe_from_topic(topic: str) -> None:
393
448
  logger.error("Unexpected error while unsubscribing from topic %s: %s", topic, e)
394
449
 
395
450
 
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."""
451
+ async def _process_api_event(event: dict) -> None:
452
+ """Process API events and add to async queue."""
406
453
  payload = event.get("payload", {})
407
454
 
408
455
  try:
@@ -423,43 +470,18 @@ def _process_api_event(event: dict, event_queue: Queue) -> None:
423
470
  # Now attempt to convert it into an EventRequest.
424
471
  try:
425
472
  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
473
  except Exception as e:
430
474
  msg = f"Unable to convert request JSON into a valid EventRequest object. Error Message: '{e}'"
431
475
  raise RuntimeError(msg) from None
432
476
 
477
+ if not isinstance(request_event, EventRequest):
478
+ msg = f"Deserialized event is not an EventRequest: {type(request_event)}"
479
+ raise TypeError(msg)
480
+
433
481
  # Check if the event implements SkipTheLineMixin for priority processing
434
482
  if isinstance(request_event.request, SkipTheLineMixin):
435
483
  # 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))
484
+ await _process_event_request(request_event)
463
485
  else:
464
- # Add the event to the queue for normal processing
465
- event_queue.put(request_event)
486
+ # Add the event to the async queue for normal processing
487
+ await griptape_nodes.EventManager().aput_event(request_event)
@@ -31,7 +31,7 @@ class ReloadHandler(PatternMatchingEventHandler):
31
31
 
32
32
  def start_process(self) -> None:
33
33
  if self.process:
34
- self.process.terminate()
34
+ self._terminate_process(self.process)
35
35
  uv_path = find_uv_bin()
36
36
  self.process = subprocess.Popen( # noqa: S603
37
37
  [uv_path, "run", "gtn"],
@@ -39,6 +39,21 @@ class ReloadHandler(PatternMatchingEventHandler):
39
39
  stderr=sys.stderr,
40
40
  )
41
41
 
42
+ def _terminate_process(self, process: subprocess.Popen) -> None:
43
+ """Gracefully terminate a process with timeout."""
44
+ if process.poll() is not None:
45
+ return # Process already terminated
46
+
47
+ # First try graceful termination
48
+ process.terminate()
49
+ try:
50
+ # Wait up to 5 seconds for graceful shutdown
51
+ process.wait(timeout=5)
52
+ except subprocess.TimeoutExpired:
53
+ # Force kill if it doesn't shut down gracefully
54
+ process.kill()
55
+ process.wait()
56
+
42
57
  def on_modified(self, event: Any) -> None:
43
58
  """Called on any file event in the watched directory (create, modify, delete, move)."""
44
59
  # Don't reload if the event is on a directory
@@ -65,7 +80,7 @@ if __name__ == "__main__":
65
80
  time.sleep(1)
66
81
  except KeyboardInterrupt:
67
82
  if event_handler.process:
68
- event_handler.process.terminate()
83
+ event_handler._terminate_process(event_handler.process)
69
84
  finally:
70
85
  observer.stop()
71
86
  observer.join()