xpander-sdk 1.60.4__py3-none-any.whl → 2.0.155__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 (90) hide show
  1. xpander_sdk/__init__.py +76 -7793
  2. xpander_sdk/consts/__init__.py +0 -0
  3. xpander_sdk/consts/api_routes.py +63 -0
  4. xpander_sdk/core/__init__.py +0 -0
  5. xpander_sdk/core/module_base.py +164 -0
  6. xpander_sdk/core/state.py +10 -0
  7. xpander_sdk/core/xpander_api_client.py +119 -0
  8. xpander_sdk/exceptions/__init__.py +0 -0
  9. xpander_sdk/exceptions/module_exception.py +45 -0
  10. xpander_sdk/models/__init__.py +0 -0
  11. xpander_sdk/models/activity.py +65 -0
  12. xpander_sdk/models/configuration.py +92 -0
  13. xpander_sdk/models/events.py +70 -0
  14. xpander_sdk/models/frameworks.py +64 -0
  15. xpander_sdk/models/shared.py +102 -0
  16. xpander_sdk/models/user.py +21 -0
  17. xpander_sdk/modules/__init__.py +0 -0
  18. xpander_sdk/modules/agents/__init__.py +0 -0
  19. xpander_sdk/modules/agents/agents_module.py +164 -0
  20. xpander_sdk/modules/agents/models/__init__.py +0 -0
  21. xpander_sdk/modules/agents/models/agent.py +477 -0
  22. xpander_sdk/modules/agents/models/agent_list.py +107 -0
  23. xpander_sdk/modules/agents/models/knowledge_bases.py +33 -0
  24. xpander_sdk/modules/agents/sub_modules/__init__.py +0 -0
  25. xpander_sdk/modules/agents/sub_modules/agent.py +953 -0
  26. xpander_sdk/modules/agents/utils/__init__.py +0 -0
  27. xpander_sdk/modules/agents/utils/generic.py +2 -0
  28. xpander_sdk/modules/backend/__init__.py +0 -0
  29. xpander_sdk/modules/backend/backend_module.py +425 -0
  30. xpander_sdk/modules/backend/frameworks/__init__.py +0 -0
  31. xpander_sdk/modules/backend/frameworks/agno.py +627 -0
  32. xpander_sdk/modules/backend/frameworks/dispatch.py +36 -0
  33. xpander_sdk/modules/backend/utils/__init__.py +0 -0
  34. xpander_sdk/modules/backend/utils/mcp_oauth.py +95 -0
  35. xpander_sdk/modules/events/__init__.py +0 -0
  36. xpander_sdk/modules/events/decorators/__init__.py +0 -0
  37. xpander_sdk/modules/events/decorators/on_boot.py +94 -0
  38. xpander_sdk/modules/events/decorators/on_shutdown.py +94 -0
  39. xpander_sdk/modules/events/decorators/on_task.py +203 -0
  40. xpander_sdk/modules/events/events_module.py +629 -0
  41. xpander_sdk/modules/events/models/__init__.py +0 -0
  42. xpander_sdk/modules/events/models/deployments.py +25 -0
  43. xpander_sdk/modules/events/models/events.py +57 -0
  44. xpander_sdk/modules/events/utils/__init__.py +0 -0
  45. xpander_sdk/modules/events/utils/generic.py +56 -0
  46. xpander_sdk/modules/events/utils/git_init.py +32 -0
  47. xpander_sdk/modules/knowledge_bases/__init__.py +0 -0
  48. xpander_sdk/modules/knowledge_bases/knowledge_bases_module.py +217 -0
  49. xpander_sdk/modules/knowledge_bases/models/__init__.py +0 -0
  50. xpander_sdk/modules/knowledge_bases/models/knowledge_bases.py +11 -0
  51. xpander_sdk/modules/knowledge_bases/sub_modules/__init__.py +0 -0
  52. xpander_sdk/modules/knowledge_bases/sub_modules/knowledge_base.py +107 -0
  53. xpander_sdk/modules/knowledge_bases/sub_modules/knowledge_base_document_item.py +40 -0
  54. xpander_sdk/modules/knowledge_bases/utils/__init__.py +0 -0
  55. xpander_sdk/modules/tasks/__init__.py +0 -0
  56. xpander_sdk/modules/tasks/models/__init__.py +0 -0
  57. xpander_sdk/modules/tasks/models/task.py +153 -0
  58. xpander_sdk/modules/tasks/models/tasks_list.py +107 -0
  59. xpander_sdk/modules/tasks/sub_modules/__init__.py +0 -0
  60. xpander_sdk/modules/tasks/sub_modules/task.py +887 -0
  61. xpander_sdk/modules/tasks/tasks_module.py +492 -0
  62. xpander_sdk/modules/tasks/utils/__init__.py +0 -0
  63. xpander_sdk/modules/tasks/utils/files.py +114 -0
  64. xpander_sdk/modules/tools_repository/__init__.py +0 -0
  65. xpander_sdk/modules/tools_repository/decorators/__init__.py +0 -0
  66. xpander_sdk/modules/tools_repository/decorators/register_tool.py +108 -0
  67. xpander_sdk/modules/tools_repository/models/__init__.py +0 -0
  68. xpander_sdk/modules/tools_repository/models/mcp.py +68 -0
  69. xpander_sdk/modules/tools_repository/models/tool_invocation_result.py +14 -0
  70. xpander_sdk/modules/tools_repository/sub_modules/__init__.py +0 -0
  71. xpander_sdk/modules/tools_repository/sub_modules/tool.py +578 -0
  72. xpander_sdk/modules/tools_repository/tools_repository_module.py +259 -0
  73. xpander_sdk/modules/tools_repository/utils/__init__.py +0 -0
  74. xpander_sdk/modules/tools_repository/utils/generic.py +57 -0
  75. xpander_sdk/modules/tools_repository/utils/local_tools.py +52 -0
  76. xpander_sdk/modules/tools_repository/utils/schemas.py +308 -0
  77. xpander_sdk/utils/__init__.py +0 -0
  78. xpander_sdk/utils/env.py +44 -0
  79. xpander_sdk/utils/event_loop.py +67 -0
  80. xpander_sdk/utils/tools.py +32 -0
  81. xpander_sdk-2.0.155.dist-info/METADATA +538 -0
  82. xpander_sdk-2.0.155.dist-info/RECORD +85 -0
  83. {xpander_sdk-1.60.4.dist-info → xpander_sdk-2.0.155.dist-info}/WHEEL +1 -1
  84. {xpander_sdk-1.60.4.dist-info → xpander_sdk-2.0.155.dist-info/licenses}/LICENSE +0 -1
  85. xpander_sdk/_jsii/__init__.py +0 -39
  86. xpander_sdk/_jsii/xpander-sdk@1.60.4.jsii.tgz +0 -0
  87. xpander_sdk/py.typed +0 -1
  88. xpander_sdk-1.60.4.dist-info/METADATA +0 -368
  89. xpander_sdk-1.60.4.dist-info/RECORD +0 -9
  90. {xpander_sdk-1.60.4.dist-info → xpander_sdk-2.0.155.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,629 @@
1
+ """
2
+ Events module for handling background tasks and event streaming in the xpander.ai platform.
3
+
4
+ This module provides functionality for managing Server Sent Events (SSE) and executing tasks
5
+ based on events within the xpander.ai platform. It supports asynchronous execution and retry logic.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import json as py_json
13
+ import os
14
+ import signal
15
+ import sys
16
+ from os import getenv
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from typing import Any, Awaitable, Callable, Optional, Set, Union, List
19
+
20
+ import httpx
21
+ from httpx_sse import aconnect_sse
22
+ from loguru import logger
23
+ from pydantic import BaseModel
24
+
25
+ from xpander_sdk.core.module_base import ModuleBase
26
+ from xpander_sdk.exceptions.module_exception import ModuleException
27
+ from xpander_sdk.models.configuration import Configuration
28
+ from xpander_sdk.models.shared import OutputFormat
29
+ from xpander_sdk.modules.agents.models.agent import SourceNodeType
30
+ from xpander_sdk.modules.tasks.tasks_module import Tasks
31
+
32
+ from .utils.git_init import configure_git_credentials
33
+ from .utils.generic import backoff_delay, get_events_base, get_events_headers
34
+ from .models.deployments import DeployedAsset
35
+ from .models.events import (
36
+ EventType,
37
+ WorkerEnvironmentConflict,
38
+ WorkerFinishedEvent,
39
+ WorkerHeartbeat,
40
+ )
41
+ from ..tasks.sub_modules.task import Task
42
+ from ..tasks.models.task import AgentExecutionStatus, LocalTaskTest
43
+
44
+
45
+ _MAX_RETRIES = 5 # total attempts (1 initial + 4 retries)
46
+
47
+ ExecutionRequestHandler = Union[
48
+ Callable[[Task], Task],
49
+ Callable[[Task], Awaitable[Task]],
50
+ ]
51
+
52
+ BootHandler = Union[
53
+ Callable[[], None],
54
+ Callable[[], Awaitable[None]],
55
+ ]
56
+
57
+ ShutdownHandler = Union[
58
+ Callable[[], None],
59
+ Callable[[], Awaitable[None]],
60
+ ]
61
+
62
+
63
+ class Events(ModuleBase):
64
+ """
65
+ Events module for managing SSE connections and task execution.
66
+
67
+ This class manages Server Sent Events (SSE) for real-time task execution requests
68
+ and integrates with agents deployed on xpander.ai. It handles event streaming,
69
+ retry logic, and background task management. The worker is directly attached
70
+ to the agent without a parent worker hierarchy.
71
+
72
+ Attributes:
73
+ worker (Optional[DeployedAsset]): Represents the deployed asset/agent worker.
74
+ test_task (Optional[LocalTaskTest]): Task to be tested within the local environment.
75
+ configuration (Configuration): SDK configuration with credentials and endpoints.
76
+
77
+ Example:
78
+ >>> events = Events()
79
+ >>> events.register(on_task=handle_task)
80
+ """
81
+
82
+ worker: Optional[DeployedAsset] = None
83
+ test_task: Optional[LocalTaskTest] = None
84
+
85
+ # Class-level registries for boot and shutdown handlers
86
+ _boot_handlers: List[BootHandler] = []
87
+ _shutdown_handlers: List[ShutdownHandler] = []
88
+
89
+ def __init__(
90
+ self,
91
+ configuration: Optional[Configuration] = None,
92
+ max_sync_workers: Optional[int] = 4,
93
+ max_retries: Optional[int] = _MAX_RETRIES,
94
+ ):
95
+ """
96
+ Initialize the Events module with configuration and worker settings.
97
+
98
+ Configures event streaming parameters and validates essential environment setup.
99
+
100
+ Args:
101
+ configuration (Optional[Configuration]): SDK configuration with credentials and endpoints. Defaults to environment configuration.
102
+ max_sync_workers (Optional[int]): Maximum number of synchronous worker threads. Defaults to 4.
103
+ max_retries (Optional[int]): Maximum retry attempts for network calls. Defaults to 5.
104
+
105
+ Raises:
106
+ ModuleException: When required environment variables are missing or configuration is incorrect.
107
+ """
108
+ super().__init__(configuration)
109
+ configure_git_credentials()
110
+
111
+ self.is_xpander_cloud = getenv("IS_XPANDER_CLOUD", "false") == "true"
112
+ self.agent_id = self.configuration.agent_id or getenv("XPANDER_AGENT_ID", None)
113
+
114
+ if not self.agent_id:
115
+ raise ModuleException(
116
+ 400, "XPANDER_AGENT_ID is missing from your environment variables."
117
+ )
118
+ if not self.configuration.organization_id:
119
+ raise ModuleException(
120
+ 400,
121
+ "XPANDER_ORGANIZATION_ID is missing from your environment variables.",
122
+ )
123
+ if not self.configuration.api_key:
124
+ raise ModuleException(
125
+ 400, "XPANDER_API_KEY is missing from your environment variables."
126
+ )
127
+
128
+ self.max_retries = max_retries
129
+
130
+ # Internal resources
131
+ self._pool: ThreadPoolExecutor = ThreadPoolExecutor(
132
+ max_workers=max_sync_workers,
133
+ thread_name_prefix="xpander-handler",
134
+ )
135
+ self._bg: Set[asyncio.Task] = set()
136
+
137
+ logger.debug(
138
+ f"Events initialised (base_url={self.configuration.base_url}, "
139
+ f"org_id={self.configuration.organization_id}, retries={self.max_retries})"
140
+ )
141
+
142
+ # lifecycle
143
+ async def start(
144
+ self,
145
+ on_execution_request: ExecutionRequestHandler,
146
+ ) -> None:
147
+ """
148
+ Start the event listener and register handlers for task execution events.
149
+
150
+ This method sets up signal handling for graceful shutdown, registers the
151
+ agent worker directly, and begins listening to task execution requests over SSE.
152
+ Use the @on_task decorator instead of calling this method directly.
153
+
154
+ Args:
155
+ on_execution_request (ExecutionRequestHandler): Callback handler
156
+ for processing task execution requests. Can be synchronous or asynchronous.
157
+ """
158
+ # Execute boot handlers first, before any event listeners are set up
159
+ await self._execute_boot_handlers()
160
+
161
+ loop = asyncio.get_running_loop()
162
+ for sig in (signal.SIGINT, signal.SIGTERM):
163
+ loop.add_signal_handler(
164
+ sig, lambda s=sig: asyncio.create_task(self.stop(s))
165
+ )
166
+
167
+ # Register agent worker directly
168
+ self.track(
169
+ asyncio.create_task(
170
+ self.register_agent_worker(self.agent_id, on_execution_request)
171
+ )
172
+ )
173
+
174
+ logger.info("Listener started; waiting for events…")
175
+ await asyncio.gather(*self._bg)
176
+
177
+ async def stop(self, sig: signal.Signals | None = None) -> None:
178
+ """
179
+ Stop the event listener and cleanup background tasks.
180
+
181
+ Args:
182
+ sig (signal.Signals | None): Signal that triggered the stop request.
183
+
184
+ Example:
185
+ >>> await events.stop()
186
+ """
187
+ if sig:
188
+ logger.info(f"Received {sig.name} – shutting down…")
189
+
190
+ for t in self._bg:
191
+ t.cancel()
192
+ if self._bg:
193
+ await asyncio.gather(*self._bg, return_exceptions=True)
194
+
195
+ self._pool.shutdown(wait=False, cancel_futures=True)
196
+ self._bg.clear()
197
+
198
+ # Execute shutdown handlers after stopping event listeners but before final cleanup
199
+ await self._execute_shutdown_handlers()
200
+
201
+ logger.info("Listener stopped.")
202
+
203
+ async def __aenter__(self) -> "Events":
204
+ return self
205
+
206
+ async def __aexit__(self, *_exc) -> bool: # noqa: D401
207
+ await self.stop()
208
+ return False
209
+
210
+ # ---------------------- HTTP helpers with retry ---------------------- #
211
+
212
+ async def _request_with_retries(
213
+ self,
214
+ method: str,
215
+ url: str,
216
+ *,
217
+ headers: dict[str, str],
218
+ json: Any | None = None,
219
+ timeout: float | None = 10.0,
220
+ ) -> httpx.Response:
221
+ """
222
+ Perform an HTTP request with automatic retries on failure.
223
+
224
+ Args:
225
+ method (str): HTTP method to use for the request (e.g., 'POST', 'GET').
226
+ url (str): The URL to which the request is sent.
227
+ headers (dict[str, str]): HTTP headers to include in the request.
228
+ json (Any | None, optional): JSON payload to send with the request.
229
+ timeout (float | None, optional): Timeout for the request.
230
+
231
+ Returns:
232
+ httpx.Response: The response object received from the request.
233
+
234
+ Raises:
235
+ Exception: If the request fails after the maximum retry attempts.
236
+ """
237
+ last_exc: Exception | None = None
238
+ for attempt in range(1, self.max_retries + 1):
239
+ try:
240
+ async with httpx.AsyncClient(timeout=timeout) as client:
241
+ response = await client.request(
242
+ method,
243
+ url,
244
+ headers=headers,
245
+ json=json,
246
+ follow_redirects=True,
247
+ )
248
+ return response
249
+ except Exception as exc: # noqa: BLE001 broad (includes timeouts)
250
+ last_exc = exc
251
+ if attempt < self.max_retries:
252
+ delay = backoff_delay(attempt)
253
+ await asyncio.sleep(delay)
254
+ else:
255
+ logger.error(
256
+ f"{method} {url} failed after {self.max_retries} attempts - exiting. ({exc})"
257
+ )
258
+ sys.exit(1)
259
+ assert last_exc is not None
260
+ raise last_exc # for static checkers
261
+
262
+ async def _release_worker(self, worker_id: str) -> None:
263
+ """
264
+ Release the worker resource after task execution completion.
265
+
266
+ Args:
267
+ worker_id (str): The unique identifier of the worker to release.
268
+ """
269
+ url = f"{get_events_base(configuration=self.configuration)}/{worker_id}?type=worker&agent_id={self.agent_id}"
270
+ await self._request_with_retries(
271
+ "POST",
272
+ url,
273
+ headers=get_events_headers(configuration=self.configuration),
274
+ json=WorkerFinishedEvent(data={}).model_dump_safe(),
275
+ )
276
+
277
+ async def _make_heartbeat(self, worker_id: str) -> None:
278
+ """
279
+ Send a heartbeat signal to maintain the worker's active status.
280
+
281
+ Args:
282
+ worker_id (str): The unique identifier of the worker to update.
283
+ """
284
+ url = f"{get_events_base(configuration=self.configuration)}/{worker_id}?type=worker&agent_id={self.agent_id}"
285
+ await self._request_with_retries(
286
+ "POST",
287
+ url,
288
+ headers=get_events_headers(configuration=self.configuration),
289
+ json=WorkerHeartbeat().model_dump_safe(),
290
+ )
291
+
292
+
293
+ # ----------------------- SSE helpers with retry ---------------------- #
294
+
295
+ async def _sse_events_with_retries(self, url: str):
296
+ """Yield Server-Sent Events with reconnect/back‑off logic using httpx‑sse."""
297
+ attempt = 1
298
+ while True:
299
+ try:
300
+ async with httpx.AsyncClient(
301
+ timeout=None, follow_redirects=True
302
+ ) as client:
303
+ if not url.endswith('/'):
304
+ url += "/"
305
+ async with aconnect_sse(
306
+ client,
307
+ "GET",
308
+ url,
309
+ headers=get_events_headers(configuration=self.configuration),
310
+ follow_redirects=True
311
+ ) as event_source:
312
+ async for sse in event_source.aiter_sse():
313
+ yield sse
314
+
315
+ # Server closed the stream gracefully – reconnect
316
+ attempt = 1
317
+ await asyncio.sleep(backoff_delay(1))
318
+
319
+ except Exception as exc: # noqa: BLE001 broad
320
+ if attempt >= self.max_retries:
321
+ logger.error(
322
+ f"SSE connection to {url} failed after {self.max_retries} attempts – exiting. ({exc})"
323
+ )
324
+ sys.exit(1)
325
+ await asyncio.sleep(backoff_delay(attempt))
326
+ attempt += 1
327
+
328
+ async def handle_task_execution_request(
329
+ self,
330
+ agent_worker: DeployedAsset,
331
+ task: Task,
332
+ on_execution_request: ExecutionRequestHandler,
333
+ ) -> None:
334
+ """
335
+ Handle an incoming task execution request.
336
+
337
+ Args:
338
+ agent_worker (DeployedAsset): The deployed asset (agent) to handle the task.
339
+ task (Task): The task object containing execution details.
340
+ on_execution_request (ExecutionRequestHandler): The handler function to process the task.
341
+ """
342
+ error = None
343
+ try:
344
+ logger.info(f"Handling task {task.id}")
345
+ await task.aset_status(status=AgentExecutionStatus.Executing)
346
+ if asyncio.iscoroutinefunction(on_execution_request):
347
+ task = await on_execution_request(task)
348
+ else:
349
+ task = await asyncio.get_running_loop().run_in_executor(
350
+ self._pool,
351
+ on_execution_request,
352
+ task,
353
+ )
354
+ except Exception as e:
355
+ logger.exception(f"Execution handler failed - {str(e)}")
356
+ error = str(e)
357
+ finally:
358
+ task_used_tokens = task.tokens
359
+ task_used_tools = task.used_tools
360
+ await self._release_worker(agent_worker.id)
361
+
362
+ if error:
363
+ task.result = error
364
+ task.status = AgentExecutionStatus.Error
365
+ elif (
366
+ task.status == AgentExecutionStatus.Executing
367
+ ): # let the handler set the status, if not set - mark as completed
368
+ task.status = AgentExecutionStatus.Completed
369
+
370
+ # in case of structured output, return as stringified json
371
+ try:
372
+ if task.output_format == OutputFormat.Json:
373
+ if isinstance(task.result, BaseModel):
374
+ task.result = task.result.model_dump_json()
375
+ if isinstance(task.result, dict) or isinstance(task.result, list):
376
+ task.result = py_json.dumps(task.result)
377
+ except Exception:
378
+ pass
379
+
380
+ await task.asave()
381
+ task.tokens = task_used_tokens
382
+ task.used_tools = task_used_tools
383
+
384
+ if task.tokens:
385
+ await task.areport_metrics()
386
+
387
+ logger.info(f"Finished handling task {task.id}")
388
+
389
+ # local test task, finish? kill the worker
390
+ if self.test_task:
391
+ logger.info("Local task handled, exiting")
392
+
393
+ # Print the task result for CLI
394
+ if task.result:
395
+ logger.info("\n" + "="*50)
396
+ logger.info("TASK RESULT:")
397
+ logger.info("="*50)
398
+ if isinstance(task.result, (dict, list)):
399
+ import json
400
+ logger.info(json.dumps(task.result, indent=2))
401
+ else:
402
+ logger.info(task.result)
403
+ logger.info("="*50 + "\n")
404
+ else:
405
+ logger.info("\n" + "="*50)
406
+ logger.info("TASK COMPLETED (No result set)")
407
+ logger.info("="*50 + "\n")
408
+
409
+ # Use os._exit to avoid exception traceback from asyncio
410
+ os._exit(0)
411
+
412
+ async def register_agent_worker(
413
+ self,
414
+ agent_id: str,
415
+ on_execution_request: ExecutionRequestHandler,
416
+ ) -> None:
417
+ """
418
+ Register a worker agent and start listening for task events.
419
+
420
+ Args:
421
+ agent_id (str): The unique identifier of the agent to register.
422
+ on_execution_request (ExecutionRequestHandler): The callback function to process task execution requests.
423
+ """
424
+ environment = "xpander" if self.is_xpander_cloud else "local"
425
+
426
+ url = f"{get_events_base(configuration=self.configuration)}/{agent_id}?environment={environment}"
427
+
428
+ async for event in self._sse_events_with_retries(url):
429
+ if event.event == EventType.EnvironmentConflict:
430
+ conflict = WorkerEnvironmentConflict(**json.loads(event.data))
431
+ logger.error(f"Conflict! - {conflict.error}")
432
+ return
433
+ if event.event == EventType.WorkerRegistration:
434
+ self.worker = agent_worker = DeployedAsset(**json.loads(event.data))
435
+ logger.info(f"Worker registered – id={agent_worker.id}")
436
+
437
+ # convenience URLs
438
+ agent_meta = agent_worker.metadata or {}
439
+ if agent_meta:
440
+ is_stg = "stg." in get_events_base(
441
+ configuration=self.configuration
442
+ ) or "localhost" in get_events_base(
443
+ configuration=self.configuration
444
+ )
445
+ chat_url = (
446
+ f"https://{agent_meta.get('unique_name', agent_id)}.agents"
447
+ )
448
+ chat_url += ".stg" if is_stg else ""
449
+ chat_url += ".xpander.ai"
450
+
451
+ builder_url = (
452
+ "https://"
453
+ + ("stg." if is_stg else "")
454
+ + f"app.xpander.ai/agents/{agent_id}"
455
+ )
456
+ logger.info(
457
+ f"Agent '{agent_meta.get('name', agent_id)}' chat: {chat_url} | builder: {builder_url}"
458
+ )
459
+
460
+ if self.test_task:
461
+ logger.info(f"Invoking agent {self.test_task.model_dump_json()}")
462
+ created_task = await Tasks(configuration=self.configuration).acreate(
463
+ agent_id=self.agent_id,
464
+ prompt=self.test_task.input.text,
465
+ file_urls=self.test_task.input.files,
466
+ user_details=self.test_task.input.user,
467
+ agent_version=self.test_task.agent_version,
468
+ worker_id=self.worker.id,
469
+ output_format=self.test_task.output_format,
470
+ output_schema=self.test_task.output_schema,
471
+ run_locally=True,
472
+ source=SourceNodeType.SDK.value
473
+ )
474
+ self.track(
475
+ asyncio.create_task(
476
+ self.handle_task_execution_request(
477
+ agent_worker, created_task, on_execution_request
478
+ )
479
+ )
480
+ )
481
+
482
+ self.track(asyncio.create_task(self.heartbeat_loop(agent_worker.id)))
483
+
484
+ elif event.event == EventType.AgentExecution:
485
+ task = Task(**json.loads(event.data), configuration=self.configuration)
486
+ self.track(
487
+ asyncio.create_task(
488
+ self.handle_task_execution_request(
489
+ agent_worker, task, on_execution_request
490
+ )
491
+ )
492
+ )
493
+
494
+
495
+ # --------------------------------------------------------------------- #
496
+ # Misc helpers #
497
+ # --------------------------------------------------------------------- #
498
+
499
+ def track(self, task: asyncio.Task) -> None:
500
+ """
501
+ Add a task to the background task set for auto-removal on completion.
502
+
503
+ Args:
504
+ task (asyncio.Task): The asynchronous task to track.
505
+ """
506
+ self._bg.add(task)
507
+ task.add_done_callback(self._bg.discard)
508
+
509
+ async def heartbeat_loop(self, worker_id: str) -> None:
510
+ """
511
+ Continuously send heartbeat signals to maintain worker's active status.
512
+
513
+ Args:
514
+ worker_id (str): The unique identifier of the worker.
515
+ """
516
+ while True:
517
+ try:
518
+ await self._make_heartbeat(worker_id)
519
+ except Exception:
520
+ # _request_with_retries handles fatal exit
521
+ pass
522
+ await asyncio.sleep(2)
523
+
524
+ def register(
525
+ self,
526
+ on_task: ExecutionRequestHandler,
527
+ test_task: Optional[LocalTaskTest] = None,
528
+ ) -> None:
529
+ """
530
+ Register the event listener with optional test task in synchronous or asynchronous environments.
531
+
532
+ Args:
533
+ on_task (ExecutionRequestHandler): Callback handler for task execution.
534
+ test_task (Optional[LocalTaskTest]): Optional local test task for diagnostics and testing.
535
+
536
+ Example:
537
+ >>> def handle_task(task):
538
+ ... # process task execution
539
+
540
+ >>> events = Events()
541
+ >>> events.register(on_task=handle_task)
542
+ """
543
+ try:
544
+ self.test_task = test_task
545
+ loop = asyncio.get_running_loop()
546
+ if loop.is_running():
547
+ loop.create_task(self.start(on_task))
548
+ else:
549
+ asyncio.run(self.start(on_task))
550
+ except RuntimeError:
551
+ # No running loop, safe to run
552
+ asyncio.run(self.start(on_task))
553
+
554
+ # --------------------------------------------------------------------- #
555
+ # Boot and Shutdown Handler Management #
556
+ # --------------------------------------------------------------------- #
557
+
558
+ @classmethod
559
+ def register_boot_handler(cls, handler: BootHandler) -> None:
560
+ """
561
+ Register a boot handler to be executed before event listeners are set up.
562
+
563
+ Args:
564
+ handler (BootHandler): The boot handler function to register.
565
+ """
566
+ cls._boot_handlers.append(handler)
567
+ logger.debug(f"Boot handler registered: {handler.__name__ if hasattr(handler, '__name__') else 'anonymous'}")
568
+
569
+ @classmethod
570
+ def register_shutdown_handler(cls, handler: ShutdownHandler) -> None:
571
+ """
572
+ Register a shutdown handler to be executed during application shutdown.
573
+
574
+ Args:
575
+ handler (ShutdownHandler): The shutdown handler function to register.
576
+ """
577
+ cls._shutdown_handlers.append(handler)
578
+ logger.debug(f"Shutdown handler registered: {handler.__name__ if hasattr(handler, '__name__') else 'anonymous'}")
579
+
580
+ @classmethod
581
+ async def _execute_boot_handlers(cls) -> None:
582
+ """
583
+ Execute all registered boot handlers.
584
+
585
+ Raises:
586
+ Exception: If any boot handler fails, the application will not start.
587
+ """
588
+ if not cls._boot_handlers:
589
+ return
590
+
591
+ logger.info(f"Executing {len(cls._boot_handlers)} boot handler(s)...")
592
+
593
+ for handler in cls._boot_handlers:
594
+ try:
595
+ if asyncio.iscoroutinefunction(handler):
596
+ await handler()
597
+ else:
598
+ handler()
599
+ logger.debug(f"Boot handler executed successfully: {handler.__name__ if hasattr(handler, '__name__') else 'anonymous'}")
600
+ except Exception as e:
601
+ logger.error(f"Boot handler failed: {handler.__name__ if hasattr(handler, '__name__') else 'anonymous'} - {e}")
602
+ raise
603
+
604
+ logger.info("All boot handlers executed successfully")
605
+
606
+ @classmethod
607
+ async def _execute_shutdown_handlers(cls) -> None:
608
+ """
609
+ Execute all registered shutdown handlers.
610
+
611
+ Note: Exceptions in shutdown handlers are logged but do not prevent shutdown.
612
+ """
613
+ if not cls._shutdown_handlers:
614
+ return
615
+
616
+ logger.info(f"Executing {len(cls._shutdown_handlers)} shutdown handler(s)...")
617
+
618
+ for handler in cls._shutdown_handlers:
619
+ try:
620
+ if asyncio.iscoroutinefunction(handler):
621
+ await handler()
622
+ else:
623
+ handler()
624
+ logger.debug(f"Shutdown handler executed successfully: {handler.__name__ if hasattr(handler, '__name__') else 'anonymous'}")
625
+ except Exception as e:
626
+ logger.error(f"Shutdown handler failed: {handler.__name__ if hasattr(handler, '__name__') else 'anonymous'} - {e}")
627
+ # Continue with other shutdown handlers even if one fails
628
+
629
+ logger.info("All shutdown handlers executed")
File without changes
@@ -0,0 +1,25 @@
1
+ from datetime import datetime
2
+ from enum import Enum
3
+ from typing import Dict, Optional
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ class DeploymentType(str, Enum):
8
+ Gateway = "gateway"
9
+ Controller = "controller"
10
+ Worker = "worker"
11
+ Redis = "redis"
12
+
13
+ class DeployedAsset(BaseModel):
14
+ id: str = Field(...,description="The asset's unique identifier")
15
+ name: str = Field(...,description="The asset's generated name")
16
+ organization_id: str = Field(...,description="The asset's organization")
17
+ type: DeploymentType = Field(...,description="The asset's type")
18
+ created_at: datetime = Field(...,description="The asset's creation date")
19
+ created_by: Optional[str] = Field(...,description="The asset's creator - used in local env workers")
20
+ last_heartbeat: datetime = Field(...,description="The asset's creation date")
21
+ configuration: Optional[Dict] = Field(default=None,description="The asset's configuration")
22
+ dedicated_agent_id: Optional[str] = Field(...,description="The asset agent id if used as a dedicated asset")
23
+ parent_asset_id: Optional[str] = Field(...,description="The asset parent id if used as a dedicated asset worker")
24
+ is_busy: Optional[bool] = Field(False,description="The asset busyness indication")
25
+ metadata: Optional[Dict] = Field(default=None,description="The asset's metadata")