griptape-nodes 0.56.0__py3-none-any.whl → 0.57.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 (39) hide show
  1. griptape_nodes/app/app.py +10 -15
  2. griptape_nodes/app/watch.py +35 -67
  3. griptape_nodes/bootstrap/utils/__init__.py +1 -0
  4. griptape_nodes/bootstrap/utils/python_subprocess_executor.py +122 -0
  5. griptape_nodes/bootstrap/workflow_executors/local_session_workflow_executor.py +418 -0
  6. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +37 -8
  7. griptape_nodes/bootstrap/workflow_executors/subprocess_workflow_executor.py +326 -0
  8. griptape_nodes/bootstrap/workflow_executors/utils/__init__.py +1 -0
  9. griptape_nodes/bootstrap/workflow_executors/utils/subprocess_script.py +51 -0
  10. griptape_nodes/bootstrap/workflow_publishers/__init__.py +1 -0
  11. griptape_nodes/bootstrap/workflow_publishers/local_workflow_publisher.py +43 -0
  12. griptape_nodes/bootstrap/workflow_publishers/subprocess_workflow_publisher.py +84 -0
  13. griptape_nodes/bootstrap/workflow_publishers/utils/__init__.py +1 -0
  14. griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +54 -0
  15. griptape_nodes/cli/commands/engine.py +4 -15
  16. griptape_nodes/cli/main.py +6 -1
  17. griptape_nodes/exe_types/core_types.py +26 -0
  18. griptape_nodes/exe_types/node_types.py +116 -1
  19. griptape_nodes/retained_mode/events/agent_events.py +2 -0
  20. griptape_nodes/retained_mode/events/base_events.py +18 -17
  21. griptape_nodes/retained_mode/events/execution_events.py +3 -1
  22. griptape_nodes/retained_mode/events/flow_events.py +5 -7
  23. griptape_nodes/retained_mode/events/mcp_events.py +363 -0
  24. griptape_nodes/retained_mode/events/node_events.py +3 -4
  25. griptape_nodes/retained_mode/griptape_nodes.py +8 -0
  26. griptape_nodes/retained_mode/managers/agent_manager.py +67 -4
  27. griptape_nodes/retained_mode/managers/event_manager.py +31 -13
  28. griptape_nodes/retained_mode/managers/flow_manager.py +76 -44
  29. griptape_nodes/retained_mode/managers/library_manager.py +7 -9
  30. griptape_nodes/retained_mode/managers/mcp_manager.py +364 -0
  31. griptape_nodes/retained_mode/managers/node_manager.py +12 -1
  32. griptape_nodes/retained_mode/managers/settings.py +40 -0
  33. griptape_nodes/retained_mode/managers/workflow_manager.py +94 -8
  34. griptape_nodes/traits/multi_options.py +5 -1
  35. griptape_nodes/traits/options.py +10 -2
  36. {griptape_nodes-0.56.0.dist-info → griptape_nodes-0.57.0.dist-info}/METADATA +2 -2
  37. {griptape_nodes-0.56.0.dist-info → griptape_nodes-0.57.0.dist-info}/RECORD +39 -26
  38. {griptape_nodes-0.56.0.dist-info → griptape_nodes-0.57.0.dist-info}/WHEEL +0 -0
  39. {griptape_nodes-0.56.0.dist-info → griptape_nodes-0.57.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,418 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import threading
6
+ from typing import TYPE_CHECKING, Any, Self
7
+
8
+ from websockets.exceptions import ConnectionClosed, ConnectionClosedError
9
+
10
+ from griptape_nodes.app.app import (
11
+ WebSocketMessage,
12
+ _create_websocket_connection,
13
+ _send_websocket_message,
14
+ )
15
+ from griptape_nodes.bootstrap.workflow_executors.local_workflow_executor import (
16
+ LocalExecutorError,
17
+ LocalWorkflowExecutor,
18
+ )
19
+ from griptape_nodes.drivers.storage import StorageBackend
20
+ from griptape_nodes.retained_mode.events.app_events import AppInitializationComplete
21
+ from griptape_nodes.retained_mode.events.base_events import (
22
+ EventRequest,
23
+ EventResultFailure,
24
+ EventResultSuccess,
25
+ ExecutionEvent,
26
+ ExecutionGriptapeNodeEvent,
27
+ ResultPayload,
28
+ )
29
+ from griptape_nodes.retained_mode.events.execution_events import (
30
+ ControlFlowCancelledEvent,
31
+ StartFlowRequest,
32
+ StartFlowResultFailure,
33
+ )
34
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import Callable
38
+ from types import TracebackType
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
44
+ def __init__(
45
+ self,
46
+ session_id: str,
47
+ storage_backend: StorageBackend = StorageBackend.LOCAL,
48
+ on_start_flow_result: Callable[[ResultPayload], None] | None = None,
49
+ ):
50
+ super().__init__(storage_backend=storage_backend)
51
+ self._session_id = session_id
52
+ self._on_start_flow_result = on_start_flow_result
53
+ self._websocket_thread: threading.Thread | None = None
54
+ self._websocket_event_loop: asyncio.AbstractEventLoop | None = None
55
+ self._websocket_event_loop_ready = threading.Event()
56
+ self._ws_outgoing_queue: asyncio.Queue | None = None
57
+ self._shutdown_event: asyncio.Event | None = None
58
+
59
+ async def __aenter__(self) -> Self:
60
+ """Async context manager entry: initialize queue and broadcast app initialization."""
61
+ GriptapeNodes.EventManager().initialize_queue()
62
+ await GriptapeNodes.EventManager().broadcast_app_event(AppInitializationComplete())
63
+
64
+ logger.info("Setting up session %s", self._session_id)
65
+ GriptapeNodes.SessionManager().save_session(self._session_id)
66
+ GriptapeNodes.SessionManager().set_active_session_id(self._session_id)
67
+ await self._start_websocket_connection()
68
+
69
+ return self
70
+
71
+ async def __aexit__(
72
+ self,
73
+ exc_type: type[BaseException] | None,
74
+ exc_val: BaseException | None,
75
+ exc_tb: TracebackType | None,
76
+ ) -> None:
77
+ """Async context manager exit."""
78
+ self._stop_websocket_thread()
79
+
80
+ GriptapeNodes.SessionManager().remove_session(self._session_id)
81
+
82
+ # TODO: Broadcast shutdown https://github.com/griptape-ai/griptape-nodes/issues/2149
83
+
84
+ def _stop_websocket_thread(self) -> None:
85
+ """Stop the websocket thread."""
86
+ if self._websocket_thread is None or not self._websocket_thread.is_alive():
87
+ logger.debug("No websocket thread to stop")
88
+ return
89
+
90
+ logger.debug("Stopping websocket thread")
91
+ self._websocket_event_loop_ready.clear()
92
+
93
+ # Signal shutdown to the websocket tasks
94
+ if self._websocket_event_loop and self._websocket_event_loop.is_running() and self._shutdown_event:
95
+
96
+ def signal_shutdown() -> None:
97
+ if self._shutdown_event:
98
+ self._shutdown_event.set()
99
+
100
+ self._websocket_event_loop.call_soon_threadsafe(signal_shutdown)
101
+
102
+ # Wait for thread to finish
103
+ self._websocket_thread.join(timeout=5.0)
104
+ if self._websocket_thread.is_alive():
105
+ logger.warning("Websocket thread did not stop gracefully")
106
+ else:
107
+ logger.info("Websocket thread stopped successfully")
108
+
109
+ async def _process_execution_event_async(self, event: ExecutionGriptapeNodeEvent) -> None:
110
+ """Process execution events asynchronously for real-time websocket emission."""
111
+ logger.debug("REAL-TIME: Processing execution event for session %s", self._session_id)
112
+ self.send_event("execution_event", event.wrapped_event.json())
113
+
114
+ async def arun(
115
+ self,
116
+ workflow_name: str,
117
+ flow_input: Any,
118
+ storage_backend: StorageBackend | None = None,
119
+ **kwargs: Any,
120
+ ) -> None:
121
+ """Executes a local workflow.
122
+
123
+ Executes a workflow by setting up event listeners, registering libraries,
124
+ loading the user-defined workflow, and running the specified workflow.
125
+
126
+ Parameters:
127
+ workflow_name: The name of the workflow to execute.
128
+ flow_input: Input data for the flow, typically a dictionary.
129
+ storage_backend: The storage backend to use for the workflow execution.
130
+
131
+ Returns:
132
+ None
133
+ """
134
+ try:
135
+ await self._arun(
136
+ workflow_name=workflow_name,
137
+ flow_input=flow_input,
138
+ storage_backend=storage_backend,
139
+ **kwargs,
140
+ )
141
+ except Exception as e:
142
+ msg = f"Workflow execution failed: {e}"
143
+ logger.exception(msg)
144
+ control_flow_cancelled_event = ControlFlowCancelledEvent(
145
+ result_details="Encountered an error during workflow execution",
146
+ exception=e,
147
+ )
148
+ execution_event = ExecutionEvent(payload=control_flow_cancelled_event)
149
+ self.send_event("execution_event", execution_event.json())
150
+ await self._wait_for_websocket_queue_flush()
151
+ await asyncio.sleep(1)
152
+ raise LocalExecutorError(msg) from e
153
+ finally:
154
+ self._stop_websocket_thread()
155
+
156
+ async def _arun( # noqa: C901, PLR0915
157
+ self,
158
+ workflow_name: str,
159
+ flow_input: Any,
160
+ storage_backend: StorageBackend | None = None,
161
+ **kwargs: Any,
162
+ ) -> None:
163
+ """Internal async run method with detailed event handling and websocket integration."""
164
+ flow_name = await self.aprepare_workflow_for_run(
165
+ workflow_name=workflow_name,
166
+ flow_input=flow_input,
167
+ storage_backend=storage_backend,
168
+ **kwargs,
169
+ )
170
+
171
+ # Send the run command to actually execute it (fire and forget)
172
+ start_flow_request = StartFlowRequest(flow_name=flow_name)
173
+ start_flow_task = asyncio.create_task(GriptapeNodes.ahandle_request(start_flow_request))
174
+
175
+ is_flow_finished = False
176
+ error: Exception | None = None
177
+
178
+ def _handle_start_flow_result(task: asyncio.Task[ResultPayload]) -> None:
179
+ nonlocal is_flow_finished, error, start_flow_request
180
+ try:
181
+ start_flow_result = task.result()
182
+ self._on_start_flow_result(start_flow_result) if self._on_start_flow_result is not None else None
183
+
184
+ if isinstance(start_flow_result, StartFlowResultFailure):
185
+ msg = f"Failed to start flow {flow_name}"
186
+ logger.error(msg)
187
+ event_result_failure = EventResultFailure(request=start_flow_request, result=start_flow_result)
188
+ self.send_event("failure_result", event_result_failure.json())
189
+ raise LocalExecutorError(msg) from start_flow_result.exception # noqa: TRY301
190
+
191
+ event_result_success = EventResultSuccess(request=start_flow_request, result=start_flow_result)
192
+ self.send_event("success_result", event_result_success.json())
193
+
194
+ except Exception as e:
195
+ msg = "Error starting workflow"
196
+ logger.exception(msg)
197
+ is_flow_finished = True
198
+ error = e
199
+ # The StartFlowRequest is sent asynchronously to enable real-time event emission via WebSocket.
200
+ # The main while loop below then waits for events from the queue. However, if StartFlowRequest fails
201
+ # immediately, then no events are ever added to the queue, causing the loop to hang indefinitely
202
+ # on event_queue.get(). This fix adds a dummy event to wake up the loop in failure cases.
203
+ event_queue = GriptapeNodes.EventManager().event_queue
204
+ queue_event_task = asyncio.create_task(event_queue.put(None))
205
+ background_tasks.add(queue_event_task)
206
+ queue_event_task.add_done_callback(background_tasks.discard)
207
+
208
+ start_flow_task.add_done_callback(_handle_start_flow_result)
209
+
210
+ logger.info("Workflow start request sent! Processing events...")
211
+
212
+ background_tasks: set[asyncio.Task] = set()
213
+
214
+ def _handle_task_done(task: asyncio.Task) -> None:
215
+ background_tasks.discard(task)
216
+ if task.exception() and not task.cancelled():
217
+ logger.exception("Background task failed", exc_info=task.exception())
218
+
219
+ event_queue = GriptapeNodes.EventManager().event_queue
220
+ while not is_flow_finished:
221
+ try:
222
+ event = await event_queue.get()
223
+
224
+ # Handle the dummy wake up event (None)
225
+ if event is None:
226
+ event_queue.task_done()
227
+ continue
228
+
229
+ logger.debug("Processing event: %s", type(event).__name__)
230
+
231
+ if isinstance(event, EventRequest):
232
+ self.send_event("event_request", event.json())
233
+ task = asyncio.create_task(self._handle_event_request(event))
234
+ background_tasks.add(task)
235
+ task.add_done_callback(_handle_task_done)
236
+ elif isinstance(event, ExecutionGriptapeNodeEvent):
237
+ # Emit execution event via WebSocket
238
+ self.send_event("execution_event", event.wrapped_event.json())
239
+ task = asyncio.create_task(self._process_execution_event_async(event))
240
+ background_tasks.add(task)
241
+ task.add_done_callback(_handle_task_done)
242
+ is_flow_finished, error = await self._handle_execution_event(event, flow_name)
243
+
244
+ event_queue.task_done()
245
+
246
+ except Exception as e:
247
+ msg = f"Error handling queue event: {e}"
248
+ logger.exception(msg)
249
+ error = LocalExecutorError(msg)
250
+ break
251
+
252
+ if background_tasks:
253
+ logger.info("Waiting for %d background tasks to complete", len(background_tasks))
254
+ await asyncio.gather(*background_tasks, return_exceptions=True)
255
+
256
+ await self._wait_for_websocket_queue_flush()
257
+
258
+ if error is not None:
259
+ raise error
260
+
261
+ async def _start_websocket_connection(self) -> None:
262
+ """Start websocket connection in a background thread for event emission."""
263
+ logger.info("Starting websocket connection for session %s", self._session_id)
264
+ api_key = self._get_api_key()
265
+ if api_key is None:
266
+ logger.warning("No API key found, websocket connection will not be established")
267
+ return
268
+
269
+ logger.info("API key found, starting websocket thread")
270
+ self._websocket_thread = threading.Thread(target=self._start_websocket_thread, args=(api_key,), daemon=True)
271
+ self._websocket_thread.start()
272
+
273
+ if self._websocket_event_loop_ready.wait(timeout=10):
274
+ logger.info("Websocket thread ready")
275
+ await asyncio.sleep(1) # Brief wait for connection to establish
276
+ else:
277
+ logger.error("Timeout waiting for websocket thread to start")
278
+
279
+ def _get_api_key(self) -> str | None:
280
+ """Get API key from secrets manager."""
281
+ try:
282
+ secrets_manager = GriptapeNodes.SecretsManager()
283
+ return secrets_manager.get_secret("GT_CLOUD_API_KEY")
284
+ except Exception:
285
+ logger.exception("Failed to get API key")
286
+ return None
287
+
288
+ def _start_websocket_thread(self, api_key: str) -> None:
289
+ """Run WebSocket tasks in a separate thread with its own async loop."""
290
+ try:
291
+ # Create a new event loop for this thread
292
+ loop = asyncio.new_event_loop()
293
+ self._websocket_event_loop = loop
294
+ asyncio.set_event_loop(loop)
295
+
296
+ # Create the outgoing queue and shutdown event
297
+ self._ws_outgoing_queue = asyncio.Queue()
298
+ self._shutdown_event = asyncio.Event()
299
+
300
+ # Signal that websocket_event_loop is ready
301
+ self._websocket_event_loop_ready.set()
302
+ logger.info("Websocket thread started and ready")
303
+
304
+ # Run the async WebSocket tasks
305
+ loop.run_until_complete(self._run_websocket_tasks(api_key))
306
+ except Exception as e:
307
+ logger.error("WebSocket thread error: %s", e)
308
+ finally:
309
+ self._websocket_event_loop = None
310
+ self._websocket_event_loop_ready.clear()
311
+ self._shutdown_event = None
312
+ logger.info("Websocket thread ended")
313
+
314
+ async def _run_websocket_tasks(self, api_key: str) -> None:
315
+ """Run websocket tasks - establish connection and handle outgoing messages."""
316
+ logger.info("Creating websocket connection stream")
317
+ connection_stream = _create_websocket_connection(api_key)
318
+
319
+ async for ws_connection in connection_stream:
320
+ logger.info("WebSocket connection established for session %s", self._session_id)
321
+ try:
322
+ # Use our own version that works with our local queue
323
+ await self._send_outgoing_messages(ws_connection)
324
+
325
+ except (ConnectionClosed, ConnectionClosedError):
326
+ logger.info("WebSocket connection closed, reconnecting...")
327
+ continue
328
+ except asyncio.CancelledError:
329
+ logger.info("WebSocket task cancelled, shutting down")
330
+ break
331
+ except Exception:
332
+ logger.exception("WebSocket tasks failed")
333
+ await asyncio.sleep(2.0)
334
+ continue
335
+
336
+ if self._shutdown_event and self._shutdown_event.is_set():
337
+ logger.info("Shutdown requested, ending WebSocket connection loop")
338
+ break
339
+
340
+ logger.info("WebSocket connection loop ended")
341
+
342
+ async def _send_outgoing_messages(self, ws_connection: Any) -> None:
343
+ """Send outgoing WebSocket messages from queue - matches app.py pattern exactly."""
344
+ if self._ws_outgoing_queue is None:
345
+ logger.error("No outgoing queue available")
346
+ return
347
+
348
+ logger.debug("Starting outgoing WebSocket request sender")
349
+
350
+ while True:
351
+ # Check if shutdown was requested
352
+ if self._shutdown_event and self._shutdown_event.is_set():
353
+ logger.info("Shutdown requested, ending message sender")
354
+ break
355
+
356
+ try:
357
+ # Get message from outgoing queue with timeout to allow shutdown checks
358
+ message = await asyncio.wait_for(self._ws_outgoing_queue.get(), timeout=1.0)
359
+ except TimeoutError:
360
+ # No message in queue, continue to check for shutdown
361
+ continue
362
+
363
+ try:
364
+ if isinstance(message, WebSocketMessage):
365
+ await _send_websocket_message(ws_connection, message.event_type, message.payload, message.topic)
366
+ logger.debug("DELIVERED: %s event", message.event_type)
367
+ else:
368
+ logger.warning("Unknown outgoing message type: %s", type(message))
369
+ except Exception as e:
370
+ logger.error("Error sending outgoing WebSocket request: %s", e)
371
+ finally:
372
+ self._ws_outgoing_queue.task_done()
373
+
374
+ def send_event(self, event_type: str, payload: str) -> None:
375
+ """Send an event via websocket if connected - thread-safe version."""
376
+ # Wait for websocket event loop to be ready
377
+ if not self._websocket_event_loop_ready.wait(timeout=1.0):
378
+ logger.debug("Websocket not ready, event not sent: %s", event_type)
379
+ return
380
+
381
+ # Use run_coroutine_threadsafe to put message into WebSocket background thread queue
382
+ if self._websocket_event_loop is None:
383
+ logger.debug("WebSocket event loop not available for message: %s", event_type)
384
+ return
385
+
386
+ topic = f"sessions/{self._session_id}/response"
387
+ message = WebSocketMessage(event_type, payload, topic)
388
+
389
+ if self._ws_outgoing_queue is None:
390
+ logger.debug("No websocket queue available for event: %s", event_type)
391
+ return
392
+
393
+ try:
394
+ asyncio.run_coroutine_threadsafe(self._ws_outgoing_queue.put(message), self._websocket_event_loop)
395
+ logger.debug("SENT: %s event via websocket", event_type)
396
+ except Exception as e:
397
+ logger.error("Failed to queue event %s: %s", event_type, e)
398
+
399
+ async def _wait_for_websocket_queue_flush(self, timeout_seconds: float = 5.0) -> None:
400
+ """Wait for all websocket messages to be sent."""
401
+ if self._ws_outgoing_queue is None or self._websocket_event_loop is None:
402
+ return
403
+
404
+ async def _check_queue_empty() -> bool:
405
+ return self._ws_outgoing_queue.empty() if self._ws_outgoing_queue else True
406
+
407
+ start_time = asyncio.get_event_loop().time()
408
+ while asyncio.get_event_loop().time() - start_time < timeout_seconds:
409
+ future = asyncio.run_coroutine_threadsafe(_check_queue_empty(), self._websocket_event_loop)
410
+ try:
411
+ is_empty = future.result(timeout=0.1)
412
+ if is_empty:
413
+ return
414
+ except Exception as e:
415
+ logger.debug("Error checking queue status: %s", e)
416
+ await asyncio.sleep(0.1)
417
+
418
+ logger.warning("Timeout waiting for websocket queue to flush")
@@ -149,25 +149,25 @@ class LocalWorkflowExecutor(WorkflowExecutor):
149
149
 
150
150
  return False, None
151
151
 
152
- async def arun(
152
+ async def aprepare_workflow_for_run(
153
153
  self,
154
154
  workflow_name: str,
155
155
  flow_input: Any,
156
156
  storage_backend: StorageBackend | None = None,
157
157
  **kwargs: Any,
158
- ) -> None:
159
- """Executes a local workflow.
160
-
161
- Executes a workflow by setting up event listeners, registering libraries,
162
- loading the user-defined workflow, and running the specified workflow.
158
+ ) -> str:
159
+ """Prepares a local workflow for execution.
163
160
 
161
+ This method sets up the environment for executing a workflow, including
162
+ initializing event listeners, registering libraries, loading the user-defined
163
+ workflow, and preparing the specified workflow for execution.
164
164
  Parameters:
165
- workflow_name: The name of the workflow to execute.
165
+ workflow_name: The name of the workflow to prepare.
166
166
  flow_input: Input data for the flow, typically a dictionary.
167
167
  storage_backend: The storage backend to use for the workflow execution.
168
168
 
169
169
  Returns:
170
- None
170
+ str: The name of the prepared flow.
171
171
  """
172
172
  if storage_backend is not None:
173
173
  msg = "The storage_backend parameter is deprecated. Pass `storage_backend` to the constructor instead."
@@ -186,6 +186,35 @@ class LocalWorkflowExecutor(WorkflowExecutor):
186
186
  # Now let's set the input to the flow
187
187
  await self._set_input_for_flow(flow_name=flow_name, flow_input=flow_input)
188
188
 
189
+ return flow_name
190
+
191
+ async def arun(
192
+ self,
193
+ workflow_name: str,
194
+ flow_input: Any,
195
+ storage_backend: StorageBackend | None = None,
196
+ **kwargs: Any,
197
+ ) -> None:
198
+ """Executes a local workflow.
199
+
200
+ Executes a workflow by setting up event listeners, registering libraries,
201
+ loading the user-defined workflow, and running the specified workflow.
202
+
203
+ Parameters:
204
+ workflow_name: The name of the workflow to execute.
205
+ flow_input: Input data for the flow, typically a dictionary.
206
+ storage_backend: The storage backend to use for the workflow execution.
207
+
208
+ Returns:
209
+ None
210
+ """
211
+ flow_name = await self.aprepare_workflow_for_run(
212
+ workflow_name=workflow_name,
213
+ flow_input=flow_input,
214
+ storage_backend=storage_backend,
215
+ **kwargs,
216
+ )
217
+
189
218
  # Now send the run command to actually execute it
190
219
  start_flow_request = StartFlowRequest(flow_name=flow_name)
191
220
  start_flow_result = await GriptapeNodes.ahandle_request(start_flow_request)