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.
- griptape_nodes/__init__.py +5 -4
- griptape_nodes/app/api.py +22 -30
- griptape_nodes/app/app.py +374 -289
- griptape_nodes/app/watch.py +17 -2
- griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +66 -103
- griptape_nodes/bootstrap/workflow_executors/workflow_executor.py +16 -4
- griptape_nodes/exe_types/core_types.py +16 -4
- griptape_nodes/exe_types/node_types.py +74 -16
- griptape_nodes/machines/control_flow.py +21 -26
- griptape_nodes/machines/fsm.py +16 -16
- griptape_nodes/machines/node_resolution.py +30 -119
- griptape_nodes/mcp_server/server.py +14 -10
- griptape_nodes/mcp_server/ws_request_manager.py +2 -2
- griptape_nodes/node_library/workflow_registry.py +5 -0
- griptape_nodes/retained_mode/events/base_events.py +12 -7
- griptape_nodes/retained_mode/events/execution_events.py +0 -6
- griptape_nodes/retained_mode/events/node_events.py +38 -0
- griptape_nodes/retained_mode/events/parameter_events.py +11 -0
- griptape_nodes/retained_mode/events/variable_events.py +361 -0
- griptape_nodes/retained_mode/events/workflow_events.py +35 -0
- griptape_nodes/retained_mode/griptape_nodes.py +61 -26
- griptape_nodes/retained_mode/managers/agent_manager.py +8 -9
- griptape_nodes/retained_mode/managers/event_manager.py +215 -74
- griptape_nodes/retained_mode/managers/flow_manager.py +39 -33
- griptape_nodes/retained_mode/managers/library_lifecycle/library_directory.py +14 -14
- griptape_nodes/retained_mode/managers/library_lifecycle/library_fsm.py +20 -20
- griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/base.py +1 -1
- griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/github.py +1 -1
- griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/local_file.py +4 -3
- griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/package.py +1 -1
- griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/sandbox.py +1 -1
- griptape_nodes/retained_mode/managers/library_manager.py +20 -19
- griptape_nodes/retained_mode/managers/node_manager.py +83 -8
- griptape_nodes/retained_mode/managers/object_manager.py +4 -0
- griptape_nodes/retained_mode/managers/settings.py +1 -0
- griptape_nodes/retained_mode/managers/sync_manager.py +3 -9
- griptape_nodes/retained_mode/managers/variable_manager.py +529 -0
- griptape_nodes/retained_mode/managers/workflow_manager.py +156 -50
- griptape_nodes/retained_mode/variable_types.py +18 -0
- griptape_nodes/utils/__init__.py +4 -0
- griptape_nodes/utils/async_utils.py +89 -0
- {griptape_nodes-0.51.2.dist-info → griptape_nodes-0.52.1.dist-info}/METADATA +2 -3
- {griptape_nodes-0.51.2.dist-info → griptape_nodes-0.52.1.dist-info}/RECORD +45 -42
- {griptape_nodes-0.51.2.dist-info → griptape_nodes-0.52.1.dist-info}/WHEEL +1 -1
- griptape_nodes/bootstrap/workflow_executors/subprocess_workflow_executor.py +0 -90
- {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
|
|
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.
|
|
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
|
-
#
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
ws_connection_for_sending = None
|
|
53
|
-
event_loop = None
|
|
63
|
+
topic: str
|
|
54
64
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
135
|
-
"""
|
|
136
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
163
|
-
"""
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
457
|
+
await _send_message(dest_socket, result_event.json(), topic=result_event.response_topic)
|
|
214
458
|
|
|
215
459
|
|
|
216
|
-
def
|
|
217
|
-
"""Process ExecutionGriptapeNodeEvents and send them to the API."""
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
474
|
+
await _send_message("execution_event", event_to_emit.json())
|
|
247
475
|
|
|
248
476
|
|
|
249
|
-
def
|
|
250
|
-
"""
|
|
251
|
-
#
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
"""Listen for events in the event queue and process them.
|
|
491
|
+
message = WebSocketMessage(event_type, payload, topic)
|
|
259
492
|
|
|
260
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
|
298
|
-
"""
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
321
|
-
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
|
|
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 =
|
|
339
|
-
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)
|