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.
- xpander_sdk/__init__.py +76 -7793
- xpander_sdk/consts/__init__.py +0 -0
- xpander_sdk/consts/api_routes.py +63 -0
- xpander_sdk/core/__init__.py +0 -0
- xpander_sdk/core/module_base.py +164 -0
- xpander_sdk/core/state.py +10 -0
- xpander_sdk/core/xpander_api_client.py +119 -0
- xpander_sdk/exceptions/__init__.py +0 -0
- xpander_sdk/exceptions/module_exception.py +45 -0
- xpander_sdk/models/__init__.py +0 -0
- xpander_sdk/models/activity.py +65 -0
- xpander_sdk/models/configuration.py +92 -0
- xpander_sdk/models/events.py +70 -0
- xpander_sdk/models/frameworks.py +64 -0
- xpander_sdk/models/shared.py +102 -0
- xpander_sdk/models/user.py +21 -0
- xpander_sdk/modules/__init__.py +0 -0
- xpander_sdk/modules/agents/__init__.py +0 -0
- xpander_sdk/modules/agents/agents_module.py +164 -0
- xpander_sdk/modules/agents/models/__init__.py +0 -0
- xpander_sdk/modules/agents/models/agent.py +477 -0
- xpander_sdk/modules/agents/models/agent_list.py +107 -0
- xpander_sdk/modules/agents/models/knowledge_bases.py +33 -0
- xpander_sdk/modules/agents/sub_modules/__init__.py +0 -0
- xpander_sdk/modules/agents/sub_modules/agent.py +953 -0
- xpander_sdk/modules/agents/utils/__init__.py +0 -0
- xpander_sdk/modules/agents/utils/generic.py +2 -0
- xpander_sdk/modules/backend/__init__.py +0 -0
- xpander_sdk/modules/backend/backend_module.py +425 -0
- xpander_sdk/modules/backend/frameworks/__init__.py +0 -0
- xpander_sdk/modules/backend/frameworks/agno.py +627 -0
- xpander_sdk/modules/backend/frameworks/dispatch.py +36 -0
- xpander_sdk/modules/backend/utils/__init__.py +0 -0
- xpander_sdk/modules/backend/utils/mcp_oauth.py +95 -0
- xpander_sdk/modules/events/__init__.py +0 -0
- xpander_sdk/modules/events/decorators/__init__.py +0 -0
- xpander_sdk/modules/events/decorators/on_boot.py +94 -0
- xpander_sdk/modules/events/decorators/on_shutdown.py +94 -0
- xpander_sdk/modules/events/decorators/on_task.py +203 -0
- xpander_sdk/modules/events/events_module.py +629 -0
- xpander_sdk/modules/events/models/__init__.py +0 -0
- xpander_sdk/modules/events/models/deployments.py +25 -0
- xpander_sdk/modules/events/models/events.py +57 -0
- xpander_sdk/modules/events/utils/__init__.py +0 -0
- xpander_sdk/modules/events/utils/generic.py +56 -0
- xpander_sdk/modules/events/utils/git_init.py +32 -0
- xpander_sdk/modules/knowledge_bases/__init__.py +0 -0
- xpander_sdk/modules/knowledge_bases/knowledge_bases_module.py +217 -0
- xpander_sdk/modules/knowledge_bases/models/__init__.py +0 -0
- xpander_sdk/modules/knowledge_bases/models/knowledge_bases.py +11 -0
- xpander_sdk/modules/knowledge_bases/sub_modules/__init__.py +0 -0
- xpander_sdk/modules/knowledge_bases/sub_modules/knowledge_base.py +107 -0
- xpander_sdk/modules/knowledge_bases/sub_modules/knowledge_base_document_item.py +40 -0
- xpander_sdk/modules/knowledge_bases/utils/__init__.py +0 -0
- xpander_sdk/modules/tasks/__init__.py +0 -0
- xpander_sdk/modules/tasks/models/__init__.py +0 -0
- xpander_sdk/modules/tasks/models/task.py +153 -0
- xpander_sdk/modules/tasks/models/tasks_list.py +107 -0
- xpander_sdk/modules/tasks/sub_modules/__init__.py +0 -0
- xpander_sdk/modules/tasks/sub_modules/task.py +887 -0
- xpander_sdk/modules/tasks/tasks_module.py +492 -0
- xpander_sdk/modules/tasks/utils/__init__.py +0 -0
- xpander_sdk/modules/tasks/utils/files.py +114 -0
- xpander_sdk/modules/tools_repository/__init__.py +0 -0
- xpander_sdk/modules/tools_repository/decorators/__init__.py +0 -0
- xpander_sdk/modules/tools_repository/decorators/register_tool.py +108 -0
- xpander_sdk/modules/tools_repository/models/__init__.py +0 -0
- xpander_sdk/modules/tools_repository/models/mcp.py +68 -0
- xpander_sdk/modules/tools_repository/models/tool_invocation_result.py +14 -0
- xpander_sdk/modules/tools_repository/sub_modules/__init__.py +0 -0
- xpander_sdk/modules/tools_repository/sub_modules/tool.py +578 -0
- xpander_sdk/modules/tools_repository/tools_repository_module.py +259 -0
- xpander_sdk/modules/tools_repository/utils/__init__.py +0 -0
- xpander_sdk/modules/tools_repository/utils/generic.py +57 -0
- xpander_sdk/modules/tools_repository/utils/local_tools.py +52 -0
- xpander_sdk/modules/tools_repository/utils/schemas.py +308 -0
- xpander_sdk/utils/__init__.py +0 -0
- xpander_sdk/utils/env.py +44 -0
- xpander_sdk/utils/event_loop.py +67 -0
- xpander_sdk/utils/tools.py +32 -0
- xpander_sdk-2.0.155.dist-info/METADATA +538 -0
- xpander_sdk-2.0.155.dist-info/RECORD +85 -0
- {xpander_sdk-1.60.4.dist-info → xpander_sdk-2.0.155.dist-info}/WHEEL +1 -1
- {xpander_sdk-1.60.4.dist-info → xpander_sdk-2.0.155.dist-info/licenses}/LICENSE +0 -1
- xpander_sdk/_jsii/__init__.py +0 -39
- xpander_sdk/_jsii/xpander-sdk@1.60.4.jsii.tgz +0 -0
- xpander_sdk/py.typed +0 -1
- xpander_sdk-1.60.4.dist-info/METADATA +0 -368
- xpander_sdk-1.60.4.dist-info/RECORD +0 -9
- {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")
|