griptape-nodes 0.57.0__py3-none-any.whl → 0.58.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 (51) hide show
  1. griptape_nodes/api_client/__init__.py +9 -0
  2. griptape_nodes/api_client/client.py +279 -0
  3. griptape_nodes/api_client/request_client.py +273 -0
  4. griptape_nodes/app/app.py +57 -150
  5. griptape_nodes/bootstrap/utils/python_subprocess_executor.py +1 -1
  6. griptape_nodes/bootstrap/workflow_executors/local_session_workflow_executor.py +22 -50
  7. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +6 -1
  8. griptape_nodes/bootstrap/workflow_executors/subprocess_workflow_executor.py +27 -46
  9. griptape_nodes/bootstrap/workflow_executors/utils/subprocess_script.py +7 -0
  10. griptape_nodes/bootstrap/workflow_publishers/local_workflow_publisher.py +3 -1
  11. griptape_nodes/bootstrap/workflow_publishers/subprocess_workflow_publisher.py +3 -1
  12. griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +16 -1
  13. griptape_nodes/common/node_executor.py +466 -0
  14. griptape_nodes/drivers/storage/base_storage_driver.py +0 -11
  15. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +7 -25
  16. griptape_nodes/drivers/storage/local_storage_driver.py +2 -2
  17. griptape_nodes/exe_types/connections.py +37 -9
  18. griptape_nodes/exe_types/core_types.py +1 -1
  19. griptape_nodes/exe_types/node_types.py +115 -22
  20. griptape_nodes/machines/control_flow.py +48 -7
  21. griptape_nodes/machines/parallel_resolution.py +98 -29
  22. griptape_nodes/machines/sequential_resolution.py +61 -22
  23. griptape_nodes/node_library/library_registry.py +24 -1
  24. griptape_nodes/node_library/workflow_registry.py +38 -2
  25. griptape_nodes/retained_mode/events/execution_events.py +8 -1
  26. griptape_nodes/retained_mode/events/flow_events.py +90 -3
  27. griptape_nodes/retained_mode/events/node_events.py +17 -10
  28. griptape_nodes/retained_mode/events/workflow_events.py +5 -0
  29. griptape_nodes/retained_mode/griptape_nodes.py +16 -219
  30. griptape_nodes/retained_mode/managers/config_manager.py +0 -46
  31. griptape_nodes/retained_mode/managers/engine_identity_manager.py +225 -74
  32. griptape_nodes/retained_mode/managers/flow_manager.py +1276 -230
  33. griptape_nodes/retained_mode/managers/library_manager.py +7 -8
  34. griptape_nodes/retained_mode/managers/node_manager.py +197 -9
  35. griptape_nodes/retained_mode/managers/secrets_manager.py +26 -0
  36. griptape_nodes/retained_mode/managers/session_manager.py +264 -227
  37. griptape_nodes/retained_mode/managers/settings.py +4 -38
  38. griptape_nodes/retained_mode/managers/static_files_manager.py +3 -3
  39. griptape_nodes/retained_mode/managers/version_compatibility_manager.py +135 -6
  40. griptape_nodes/retained_mode/managers/workflow_manager.py +206 -78
  41. griptape_nodes/servers/mcp.py +23 -15
  42. griptape_nodes/utils/async_utils.py +36 -0
  43. griptape_nodes/utils/dict_utils.py +8 -2
  44. griptape_nodes/version_compatibility/versions/v0_39_0/modified_parameters_set_removal.py +11 -6
  45. griptape_nodes/version_compatibility/workflow_versions/v0_7_0/local_executor_argument_addition.py +12 -5
  46. {griptape_nodes-0.57.0.dist-info → griptape_nodes-0.58.0.dist-info}/METADATA +4 -3
  47. {griptape_nodes-0.57.0.dist-info → griptape_nodes-0.58.0.dist-info}/RECORD +49 -47
  48. {griptape_nodes-0.57.0.dist-info → griptape_nodes-0.58.0.dist-info}/WHEEL +1 -1
  49. griptape_nodes/retained_mode/utils/engine_identity.py +0 -245
  50. griptape_nodes/servers/ws_request_manager.py +0 -268
  51. {griptape_nodes-0.57.0.dist-info → griptape_nodes-0.58.0.dist-info}/entry_points.txt +0 -0
griptape_nodes/app/app.py CHANGED
@@ -3,20 +3,13 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import json
5
5
  import logging
6
- import os
7
- import sys
8
6
  import threading
9
7
  from dataclasses import dataclass
10
- from typing import Any
11
- from urllib.parse import urljoin
12
8
 
13
- from rich.align import Align
14
9
  from rich.console import Console
15
10
  from rich.logging import RichHandler
16
- from rich.panel import Panel
17
- from websockets.asyncio.client import connect
18
- from websockets.exceptions import ConnectionClosed, ConnectionClosedError, WebSocketException
19
11
 
12
+ from griptape_nodes.api_client import Client
20
13
  from griptape_nodes.retained_mode.events import app_events, execution_events
21
14
 
22
15
  # This import is necessary to register all events, even if not technically used
@@ -98,11 +91,15 @@ class EventLogHandler(logging.Handler):
98
91
  logger = logging.getLogger("griptape_nodes_app")
99
92
 
100
93
  griptape_nodes_logger = logging.getLogger("griptape_nodes")
94
+ griptape_nodes_logger.addHandler(EventLogHandler())
95
+ griptape_nodes_logger.setLevel(logging.INFO)
96
+
97
+ # Root logger only gets RichHandler for console output
101
98
  logging.basicConfig(
102
99
  level=logging.INFO,
103
100
  format="%(message)s",
104
101
  datefmt="[%X]",
105
- handlers=[EventLogHandler(), RichHandler(show_time=True, show_path=False, markup=True, rich_tracebacks=True)],
102
+ handlers=[RichHandler(show_time=True, show_path=False, markup=True, rich_tracebacks=True)],
106
103
  )
107
104
 
108
105
  console = Console()
@@ -120,16 +117,12 @@ def start_app() -> None:
120
117
 
121
118
  async def astart_app() -> None:
122
119
  """New async app entry point."""
123
- api_key = _ensure_api_key()
124
-
125
120
  # Initialize event queue in main thread
126
121
  griptape_nodes.EventManager().initialize_queue()
127
122
 
128
123
  try:
129
124
  # Start WebSocket tasks in daemon thread
130
- threading.Thread(
131
- target=_start_websocket_connection, args=(api_key,), daemon=True, name="websocket-tasks"
132
- ).start()
125
+ threading.Thread(target=_start_websocket_connection, daemon=True, name="websocket-tasks").start()
133
126
 
134
127
  # Run event processing on main thread
135
128
  await _process_event_queue()
@@ -139,7 +132,7 @@ async def astart_app() -> None:
139
132
  raise
140
133
 
141
134
 
142
- def _start_websocket_connection(api_key: str) -> None:
135
+ def _start_websocket_connection() -> None:
143
136
  """Run WebSocket tasks in a separate thread with its own async loop."""
144
137
  global websocket_event_loop # noqa: PLW0603
145
138
  try:
@@ -152,7 +145,7 @@ def _start_websocket_connection(api_key: str) -> None:
152
145
  websocket_event_loop_ready.set()
153
146
 
154
147
  # Run the async WebSocket tasks
155
- loop.run_until_complete(_run_websocket_tasks(api_key))
148
+ loop.run_until_complete(_run_websocket_tasks())
156
149
  except Exception as e:
157
150
  logger.error("WebSocket thread error: %s", e)
158
151
  raise
@@ -161,80 +154,40 @@ def _start_websocket_connection(api_key: str) -> None:
161
154
  websocket_event_loop_ready.clear()
162
155
 
163
156
 
164
- async def _run_websocket_tasks(api_key: str) -> None:
157
+ async def _run_websocket_tasks() -> None:
165
158
  """Run WebSocket tasks - async version."""
166
- # Create WebSocket connection for this thread
167
- connection_stream = _create_websocket_connection(api_key)
168
-
169
- # Track if this is the first connection
170
- initialized = False
171
-
172
- async for ws_connection in connection_stream:
159
+ async with Client() as client:
173
160
  logger.debug("WebSocket connection established")
174
- try:
175
- # Emit initialization event only for the first connection
176
- if not initialized:
177
- griptape_nodes.EventManager().put_event(AppEvent(payload=app_events.AppInitializationComplete()))
178
- initialized = True
179
-
180
- # Emit connection established event for every connection
181
- griptape_nodes.EventManager().put_event(AppEvent(payload=app_events.AppConnectionEstablished()))
182
-
183
- async with asyncio.TaskGroup() as tg:
184
- tg.create_task(_process_incoming_messages(ws_connection))
185
- tg.create_task(_send_outgoing_messages(ws_connection))
186
- except (ExceptionGroup, ConnectionClosed, ConnectionClosedError):
187
- logger.info("WebSocket connection closed, reconnecting...")
188
- continue
189
- except Exception:
190
- logger.exception("WebSocket tasks failed")
191
- await asyncio.sleep(2.0) # Wait before retry
192
- continue
193
-
194
-
195
- def _ensure_api_key() -> str:
196
- secrets_manager = griptape_nodes.SecretsManager()
197
- api_key = secrets_manager.get_secret("GT_CLOUD_API_KEY")
198
- if api_key is None:
199
- message = Panel(
200
- Align.center(
201
- "[bold red]Nodes API key is not set, please run [code]gtn init[/code] with a valid key: [/bold red]"
202
- "[code]gtn init --api-key <your key>[/code]\n"
203
- "[bold red]You can generate a new key from [/bold red][bold blue][link=https://nodes.griptape.ai]https://nodes.griptape.ai[/link][/bold blue]",
204
- ),
205
- title="[red]X[/red] Missing Nodes API Key",
206
- border_style="red",
207
- padding=(1, 4),
208
- )
209
- console.print(message)
210
- sys.exit(1)
211
161
 
212
- return api_key
162
+ griptape_nodes.EventManager().put_event(AppEvent(payload=app_events.AppInitializationComplete()))
163
+ griptape_nodes.EventManager().put_event(AppEvent(payload=app_events.AppConnectionEstablished()))
213
164
 
165
+ async with asyncio.TaskGroup() as tg:
166
+ tg.create_task(_process_incoming_messages(client))
167
+ tg.create_task(_send_outgoing_messages(client))
214
168
 
215
- async def _process_incoming_messages(ws_connection: Any) -> None:
169
+
170
+ async def _process_incoming_messages(client: Client) -> None:
216
171
  """Process incoming WebSocket requests from Nodes API."""
217
172
  logger.debug("Processing incoming WebSocket requests from WebSocket connection")
218
173
 
219
- async for message in ws_connection:
220
- try:
221
- data = json.loads(message)
222
- await _process_api_event(data)
223
- except Exception:
224
- logger.exception("Error processing event, skipping.")
174
+ topics = ["request"]
175
+ engine_id = griptape_nodes.get_engine_id()
176
+ if engine_id:
177
+ topics.append(f"engines/{engine_id}/request")
225
178
 
179
+ session_id = griptape_nodes.get_session_id()
180
+ if session_id:
181
+ topics.append(f"sessions/{session_id}/request")
226
182
 
227
- def _create_websocket_connection(api_key: str) -> Any:
228
- """Create an async WebSocket connection to the Nodes API."""
229
- endpoint = urljoin(
230
- os.getenv("GRIPTAPE_NODES_API_BASE_URL", "https://api.nodes.griptape.ai").replace("http", "ws"),
231
- "/ws/engines/events?version=v2",
232
- )
183
+ for topic in topics:
184
+ await client.subscribe(topic)
233
185
 
234
- return connect(
235
- endpoint,
236
- additional_headers={"Authorization": f"Bearer {api_key}"},
237
- )
186
+ async for message in client.messages:
187
+ try:
188
+ await _process_api_event(message)
189
+ except Exception:
190
+ logger.exception("Error processing event, skipping.")
238
191
 
239
192
 
240
193
  async def _process_api_event(event: dict) -> None:
@@ -276,7 +229,7 @@ async def _process_api_event(event: dict) -> None:
276
229
  griptape_nodes.EventManager().put_event(request_event)
277
230
 
278
231
 
279
- async def _send_outgoing_messages(ws_connection: Any) -> None:
232
+ async def _send_outgoing_messages(client: Client) -> None:
280
233
  """Send outgoing WebSocket requests from queue on background thread."""
281
234
  logger.debug("Starting outgoing WebSocket request sender")
282
235
 
@@ -286,11 +239,14 @@ async def _send_outgoing_messages(ws_connection: Any) -> None:
286
239
 
287
240
  try:
288
241
  if isinstance(message, WebSocketMessage):
289
- await _send_websocket_message(ws_connection, message.event_type, message.payload, message.topic)
242
+ # Use client to publish message
243
+ topic = message.topic if message.topic else _determine_response_topic()
244
+ payload_dict = json.loads(message.payload)
245
+ await client.publish(message.event_type, payload_dict, topic)
290
246
  elif isinstance(message, SubscribeCommand):
291
- await _send_subscribe_command(ws_connection, message.topic)
247
+ await client.subscribe(message.topic)
292
248
  elif isinstance(message, UnsubscribeCommand):
293
- await _send_unsubscribe_command(ws_connection, message.topic)
249
+ await client.unsubscribe(message.topic)
294
250
  else:
295
251
  logger.warning("Unknown outgoing message type: %s", type(message))
296
252
  except Exception as e:
@@ -299,44 +255,6 @@ async def _send_outgoing_messages(ws_connection: Any) -> None:
299
255
  ws_outgoing_queue.task_done()
300
256
 
301
257
 
302
- async def _send_websocket_message(ws_connection: Any, event_type: str, payload: str, topic: str | None) -> None:
303
- """Send a message via WebSocket."""
304
- try:
305
- if topic is None:
306
- topic = determine_response_topic()
307
-
308
- body = {"type": event_type, "payload": json.loads(payload), "topic": topic}
309
- await ws_connection.send(json.dumps(body))
310
- except WebSocketException as e:
311
- logger.error("Error sending WebSocket message: %s", e)
312
- except Exception as e:
313
- logger.error("Unexpected error sending WebSocket message: %s", e)
314
-
315
-
316
- async def _send_subscribe_command(ws_connection: Any, topic: str) -> None:
317
- """Send subscribe command via WebSocket."""
318
- try:
319
- body = {"type": "subscribe", "topic": topic, "payload": {}}
320
- await ws_connection.send(json.dumps(body))
321
- logger.debug("Subscribed to topic: %s", topic)
322
- except WebSocketException as e:
323
- logger.error("Error subscribing to topic %s: %s", topic, e)
324
- except Exception as e:
325
- logger.error("Unexpected error subscribing to topic %s: %s", topic, e)
326
-
327
-
328
- async def _send_unsubscribe_command(ws_connection: Any, topic: str) -> None:
329
- """Send unsubscribe command via WebSocket."""
330
- try:
331
- body = {"type": "unsubscribe", "topic": topic, "payload": {}}
332
- await ws_connection.send(json.dumps(body))
333
- logger.debug("Unsubscribed from topic: %s", topic)
334
- except WebSocketException as e:
335
- logger.error("Error unsubscribing from topic %s: %s", topic, e)
336
- except Exception as e:
337
- logger.error("Unexpected error unsubscribing from topic %s: %s", topic, e)
338
-
339
-
340
258
  async def _process_event_queue() -> None:
341
259
  """Process events concurrently - runs on main thread."""
342
260
  logger.debug("Starting event queue processor on main thread")
@@ -382,13 +300,7 @@ async def _process_event_request(event: EventRequest) -> None:
382
300
  event.request,
383
301
  result_context={"response_topic": event.response_topic, "request_id": event.request_id},
384
302
  )
385
-
386
- if result_event.result.succeeded():
387
- dest_socket = "success_result"
388
- else:
389
- dest_socket = "failure_result"
390
-
391
- await _send_message(dest_socket, result_event.json(), topic=result_event.response_topic)
303
+ await _process_node_event(GriptapeNodeEvent(wrapped_event=result_event))
392
304
 
393
305
 
394
306
  async def _process_app_event(event: AppEvent) -> None:
@@ -403,8 +315,21 @@ async def _process_node_event(event: GriptapeNodeEvent) -> None:
403
315
  """Process GriptapeNodeEvents and send them to the API (async version)."""
404
316
  # Emit the result back to the GUI
405
317
  result_event = event.wrapped_event
318
+
406
319
  if isinstance(result_event, EventResultSuccess):
407
320
  dest_socket = "success_result"
321
+ # Handle session-specific topic subscriptions
322
+ if isinstance(result_event.result, app_events.AppStartSessionResultSuccess):
323
+ session_id = result_event.result.session_id
324
+ topic = f"sessions/{session_id}/request"
325
+ await _subscribe_to_topic(topic)
326
+ logger.info("Subscribed to session topic: %s", topic)
327
+ elif isinstance(result_event.result, app_events.AppEndSessionResultSuccess):
328
+ session_id = result_event.result.session_id
329
+ if session_id is not None:
330
+ topic = f"sessions/{session_id}/request"
331
+ await _unsubscribe_from_topic(topic)
332
+ logger.info("Unsubscribed from session topic: %s", topic)
408
333
  elif isinstance(result_event, EventResultFailure):
409
334
  dest_socket = "failure_result"
410
335
  else:
@@ -443,14 +368,14 @@ async def _send_message(event_type: str, payload: str, topic: str | None = None)
443
368
 
444
369
  # Determine topic based on session_id and engine_id in the payload
445
370
  if topic is None:
446
- topic = determine_response_topic()
371
+ topic = _determine_response_topic()
447
372
 
448
373
  message = WebSocketMessage(event_type, payload, topic)
449
374
 
450
375
  asyncio.run_coroutine_threadsafe(ws_outgoing_queue.put(message), websocket_event_loop)
451
376
 
452
377
 
453
- async def subscribe_to_topic(topic: str) -> None:
378
+ async def _subscribe_to_topic(topic: str) -> None:
454
379
  """Queue a subscribe command for WebSocket using run_coroutine_threadsafe."""
455
380
  # Wait for websocket event loop to be ready
456
381
  websocket_event_loop_ready.wait()
@@ -462,7 +387,7 @@ async def subscribe_to_topic(topic: str) -> None:
462
387
  asyncio.run_coroutine_threadsafe(ws_outgoing_queue.put(SubscribeCommand(topic)), websocket_event_loop)
463
388
 
464
389
 
465
- async def unsubscribe_from_topic(topic: str) -> None:
390
+ async def _unsubscribe_from_topic(topic: str) -> None:
466
391
  """Queue an unsubscribe command for WebSocket using run_coroutine_threadsafe."""
467
392
  if websocket_event_loop is None:
468
393
  logger.error("WebSocket event loop not available for unsubscribe")
@@ -471,7 +396,7 @@ async def unsubscribe_from_topic(topic: str) -> None:
471
396
  asyncio.run_coroutine_threadsafe(ws_outgoing_queue.put(UnsubscribeCommand(topic)), websocket_event_loop)
472
397
 
473
398
 
474
- def determine_response_topic() -> str | None:
399
+ def _determine_response_topic() -> str:
475
400
  """Determine the response topic based on session_id and engine_id in the payload."""
476
401
  engine_id = griptape_nodes.get_engine_id()
477
402
  session_id = griptape_nodes.get_session_id()
@@ -487,21 +412,3 @@ def determine_response_topic() -> str | None:
487
412
 
488
413
  # Default to generic response topic
489
414
  return "response"
490
-
491
-
492
- def determine_request_topic() -> str | None:
493
- """Determine the request topic based on session_id and engine_id in the payload."""
494
- engine_id = griptape_nodes.get_engine_id()
495
- session_id = griptape_nodes.get_session_id()
496
-
497
- # Normal topic determination logic
498
- # Check for session_id first (highest priority)
499
- if session_id:
500
- return f"sessions/{session_id}/request"
501
-
502
- # Check for engine_id if no session_id
503
- if engine_id:
504
- return f"engines/{engine_id}/request"
505
-
506
- # Default to generic request topic
507
- return "request"
@@ -69,7 +69,7 @@ class PythonSubprocessExecutor:
69
69
  raise RuntimeError(msg) # noqa: TRY301
70
70
 
71
71
  except Exception as e:
72
- msg = "Error running subprocess"
72
+ msg = f"Error running subprocess: {e}"
73
73
  logger.exception(msg)
74
74
  raise PythonSubprocessExecutorError(msg) from e
75
75
  finally:
@@ -1,17 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import json
4
5
  import logging
5
6
  import threading
6
7
  from typing import TYPE_CHECKING, Any, Self
7
8
 
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
- )
9
+ from griptape_nodes.api_client import Client
10
+ from griptape_nodes.app.app import WebSocketMessage
15
11
  from griptape_nodes.bootstrap.workflow_executors.local_workflow_executor import (
16
12
  LocalExecutorError,
17
13
  LocalWorkflowExecutor,
@@ -63,7 +59,7 @@ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
63
59
 
64
60
  logger.info("Setting up session %s", self._session_id)
65
61
  GriptapeNodes.SessionManager().save_session(self._session_id)
66
- GriptapeNodes.SessionManager().set_active_session_id(self._session_id)
62
+ GriptapeNodes.SessionManager().active_session_id = self._session_id
67
63
  await self._start_websocket_connection()
68
64
 
69
65
  return self
@@ -169,7 +165,10 @@ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
169
165
  )
170
166
 
171
167
  # Send the run command to actually execute it (fire and forget)
172
- start_flow_request = StartFlowRequest(flow_name=flow_name)
168
+ pickle_control_flow_result = kwargs.get("pickle_control_flow_result", False)
169
+ start_flow_request = StartFlowRequest(
170
+ flow_name=flow_name, pickle_control_flow_result=pickle_control_flow_result
171
+ )
173
172
  start_flow_task = asyncio.create_task(GriptapeNodes.ahandle_request(start_flow_request))
174
173
 
175
174
  is_flow_finished = False
@@ -261,13 +260,7 @@ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
261
260
  async def _start_websocket_connection(self) -> None:
262
261
  """Start websocket connection in a background thread for event emission."""
263
262
  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)
263
+ self._websocket_thread = threading.Thread(target=self._start_websocket_thread, daemon=True)
271
264
  self._websocket_thread.start()
272
265
 
273
266
  if self._websocket_event_loop_ready.wait(timeout=10):
@@ -276,16 +269,7 @@ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
276
269
  else:
277
270
  logger.error("Timeout waiting for websocket thread to start")
278
271
 
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:
272
+ def _start_websocket_thread(self) -> None:
289
273
  """Run WebSocket tasks in a separate thread with its own async loop."""
290
274
  try:
291
275
  # Create a new event loop for this thread
@@ -302,7 +286,7 @@ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
302
286
  logger.info("Websocket thread started and ready")
303
287
 
304
288
  # Run the async WebSocket tasks
305
- loop.run_until_complete(self._run_websocket_tasks(api_key))
289
+ loop.run_until_complete(self._run_websocket_tasks())
306
290
  except Exception as e:
307
291
  logger.error("WebSocket thread error: %s", e)
308
292
  finally:
@@ -311,35 +295,21 @@ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
311
295
  self._shutdown_event = None
312
296
  logger.info("Websocket thread ended")
313
297
 
314
- async def _run_websocket_tasks(self, api_key: str) -> None:
298
+ async def _run_websocket_tasks(self) -> None:
315
299
  """Run websocket tasks - establish connection and handle outgoing messages."""
316
- logger.info("Creating websocket connection stream")
317
- connection_stream = _create_websocket_connection(api_key)
300
+ logger.info("Creating Client for session %s", self._session_id)
318
301
 
319
- async for ws_connection in connection_stream:
302
+ async with Client() as client:
320
303
  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
304
 
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
305
+ try:
306
+ await self._send_outgoing_messages(client)
331
307
  except Exception:
332
308
  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")
309
+ finally:
310
+ logger.info("WebSocket connection loop ended")
341
311
 
342
- async def _send_outgoing_messages(self, ws_connection: Any) -> None:
312
+ async def _send_outgoing_messages(self, client: Client) -> None:
343
313
  """Send outgoing WebSocket messages from queue - matches app.py pattern exactly."""
344
314
  if self._ws_outgoing_queue is None:
345
315
  logger.error("No outgoing queue available")
@@ -362,7 +332,9 @@ class LocalSessionWorkflowExecutor(LocalWorkflowExecutor):
362
332
 
363
333
  try:
364
334
  if isinstance(message, WebSocketMessage):
365
- await _send_websocket_message(ws_connection, message.event_type, message.payload, message.topic)
335
+ topic = message.topic if message.topic else f"sessions/{self._session_id}/response"
336
+ payload_dict = json.loads(message.payload)
337
+ await client.publish(message.event_type, payload_dict, topic)
366
338
  logger.debug("DELIVERED: %s event", message.event_type)
367
339
  else:
368
340
  logger.warning("Unknown outgoing message type: %s", type(message))
@@ -104,6 +104,8 @@ class LocalWorkflowExecutor(WorkflowExecutor):
104
104
  for node_name, node in nodes.items():
105
105
  if isinstance(node, EndNode):
106
106
  output[node_name] = node.parameter_values
107
+ # Parameter_output_values should also be included, and should take priority over parameter_values
108
+ output[node_name].update(node.parameter_output_values)
107
109
 
108
110
  return output
109
111
 
@@ -216,7 +218,10 @@ class LocalWorkflowExecutor(WorkflowExecutor):
216
218
  )
217
219
 
218
220
  # Now send the run command to actually execute it
219
- start_flow_request = StartFlowRequest(flow_name=flow_name)
221
+ pickle_control_flow_result = kwargs.get("pickle_control_flow_result", False)
222
+ start_flow_request = StartFlowRequest(
223
+ flow_name=flow_name, pickle_control_flow_result=pickle_control_flow_result
224
+ )
220
225
  start_flow_result = await GriptapeNodes.ahandle_request(start_flow_request)
221
226
 
222
227
  if start_flow_result.failed():
@@ -10,9 +10,8 @@ from pathlib import Path
10
10
  from typing import TYPE_CHECKING, Any, Self
11
11
 
12
12
  import anyio
13
- from websockets.exceptions import ConnectionClosed, ConnectionClosedError
14
13
 
15
- from griptape_nodes.app.app import _create_websocket_connection, _send_subscribe_command
14
+ from griptape_nodes.api_client import Client
16
15
  from griptape_nodes.bootstrap.utils.python_subprocess_executor import PythonSubprocessExecutor
17
16
  from griptape_nodes.bootstrap.workflow_executors.local_session_workflow_executor import LocalSessionWorkflowExecutor
18
17
  from griptape_nodes.drivers.storage import StorageBackend
@@ -80,6 +79,8 @@ class SubprocessWorkflowExecutor(LocalSessionWorkflowExecutor, PythonSubprocessE
80
79
  workflow_name: str, # noqa: ARG002
81
80
  flow_input: Any,
82
81
  storage_backend: StorageBackend = StorageBackend.LOCAL,
82
+ *,
83
+ pickle_control_flow_result: bool = False,
83
84
  **kwargs: Any, # noqa: ARG002
84
85
  ) -> None:
85
86
  """Execute a workflow in a subprocess and wait for completion."""
@@ -117,6 +118,9 @@ class SubprocessWorkflowExecutor(LocalSessionWorkflowExecutor, PythonSubprocessE
117
118
  str(tmp_workflow_path),
118
119
  ]
119
120
 
121
+ if pickle_control_flow_result:
122
+ args.append("--pickle-control-flow-result")
123
+
120
124
  try:
121
125
  await self.execute_python_script(
122
126
  script_path=tmp_script_path,
@@ -135,13 +139,7 @@ class SubprocessWorkflowExecutor(LocalSessionWorkflowExecutor, PythonSubprocessE
135
139
  async def _start_websocket_listener(self) -> None:
136
140
  """Start WebSocket connection to listen for events from the subprocess."""
137
141
  logger.info("Starting WebSocket listener for session %s", self._session_id)
138
- api_key = self._get_api_key()
139
- if api_key is None:
140
- logger.warning("No API key found, WebSocket listener will not be started")
141
- return
142
-
143
- logger.info("API key found, starting WebSocket listener thread")
144
- self._websocket_thread = threading.Thread(target=self._start_websocket_thread, args=(api_key,), daemon=True)
142
+ self._websocket_thread = threading.Thread(target=self._start_websocket_thread, daemon=True)
145
143
  self._websocket_thread.start()
146
144
 
147
145
  if self._websocket_event_loop_ready.wait(timeout=10):
@@ -173,7 +171,7 @@ class SubprocessWorkflowExecutor(LocalSessionWorkflowExecutor, PythonSubprocessE
173
171
  else:
174
172
  logger.info("WebSocket listener thread stopped successfully")
175
173
 
176
- def _start_websocket_thread(self, api_key: str) -> None:
174
+ def _start_websocket_thread(self) -> None:
177
175
  """Run WebSocket tasks in a separate thread with its own async loop."""
178
176
  try:
179
177
  # Create a new event loop for this thread
@@ -189,7 +187,7 @@ class SubprocessWorkflowExecutor(LocalSessionWorkflowExecutor, PythonSubprocessE
189
187
  logger.info("WebSocket listener thread started and ready")
190
188
 
191
189
  # Run the async WebSocket listener
192
- loop.run_until_complete(self._run_websocket_listener(api_key))
190
+ loop.run_until_complete(self._run_websocket_listener())
193
191
  except Exception as e:
194
192
  logger.error("WebSocket listener thread error: %s", e)
195
193
  finally:
@@ -198,59 +196,37 @@ class SubprocessWorkflowExecutor(LocalSessionWorkflowExecutor, PythonSubprocessE
198
196
  self._shutdown_event = None
199
197
  logger.info("WebSocket listener thread ended")
200
198
 
201
- async def _run_websocket_listener(self, api_key: str) -> None:
199
+ async def _run_websocket_listener(self) -> None:
202
200
  """Run WebSocket listener - establish connection and handle incoming messages."""
203
- logger.info("Creating WebSocket connection stream for listening")
204
- connection_stream = _create_websocket_connection(api_key)
201
+ logger.info("Creating Client for listening on session %s", self._session_id)
205
202
 
206
- async for ws_connection in connection_stream:
203
+ async with Client() as client:
207
204
  logger.info("WebSocket connection established for session %s", self._session_id)
205
+
208
206
  try:
209
- # Listen for incoming messages
210
- await self._listen_for_messages(ws_connection)
211
-
212
- except (ConnectionClosed, ConnectionClosedError):
213
- logger.info("WebSocket connection closed, reconnecting...")
214
- continue
215
- except asyncio.CancelledError:
216
- logger.info("WebSocket listener task cancelled, shutting down")
217
- break
207
+ await self._listen_for_messages(client)
218
208
  except Exception:
219
209
  logger.exception("WebSocket listener failed")
220
- await asyncio.sleep(2.0)
221
- continue
222
-
223
- # Check if shutdown was requested
224
- if self._shutdown_event and self._shutdown_event.is_set():
225
- logger.info("Shutdown requested, ending WebSocket listener connection loop")
226
- break
227
-
228
- logger.info("WebSocket listener connection loop ended")
210
+ finally:
211
+ logger.info("WebSocket listener connection loop ended")
229
212
 
230
- async def _listen_for_messages(self, ws_connection: Any) -> None:
213
+ async def _listen_for_messages(self, client: Client) -> None:
231
214
  """Listen for incoming WebSocket messages from the subprocess."""
232
215
  logger.info("Starting to listen for WebSocket messages")
233
216
 
234
- # Subscribe to the session topic to receive messages
235
217
  topic = f"sessions/{self._session_id}/response"
218
+ await client.subscribe(topic)
236
219
 
237
220
  try:
238
- await _send_subscribe_command(
239
- ws_connection=ws_connection,
240
- topic=topic,
241
- )
242
- async for message in ws_connection:
221
+ async for message in client.messages:
243
222
  if self._shutdown_event and self._shutdown_event.is_set():
244
223
  logger.info("Shutdown requested, ending message listener")
245
224
  break
246
225
 
247
226
  try:
248
- data = json.loads(message)
249
- logger.debug("Received WebSocket message: %s", data.get("type"))
250
- await self._process_event(data)
227
+ logger.debug("Received WebSocket message: %s", message.get("type"))
228
+ await self._process_event(message)
251
229
 
252
- except json.JSONDecodeError:
253
- logger.warning("Failed to parse WebSocket message: %s", message)
254
230
  except Exception:
255
231
  logger.exception("Error processing WebSocket message")
256
232
 
@@ -285,7 +261,12 @@ class SubprocessWorkflowExecutor(LocalSessionWorkflowExecutor, PythonSubprocessE
285
261
 
286
262
  if isinstance(ex_event.payload, ControlFlowResolvedEvent):
287
263
  logger.info("Workflow execution completed successfully")
288
- self.output = {ex_event.payload.end_node_name: ex_event.payload.parameter_output_values}
264
+ # Store both parameter output values and unique UUID values for deserialization
265
+ result = {
266
+ "parameter_output_values": ex_event.payload.parameter_output_values,
267
+ "unique_parameter_uuid_to_values": ex_event.payload.unique_parameter_uuid_to_values,
268
+ }
269
+ self.output = {ex_event.payload.end_node_name: result}
289
270
 
290
271
  if isinstance(ex_event.payload, ControlFlowCancelledEvent):
291
272
  logger.error("Workflow execution cancelled")