soorma-core 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- soorma/__init__.py +138 -0
- soorma/agents/__init__.py +17 -0
- soorma/agents/base.py +523 -0
- soorma/agents/planner.py +391 -0
- soorma/agents/tool.py +373 -0
- soorma/agents/worker.py +385 -0
- soorma/ai/event_toolkit.py +281 -0
- soorma/ai/tools.py +280 -0
- soorma/cli/__init__.py +7 -0
- soorma/cli/commands/__init__.py +3 -0
- soorma/cli/commands/dev.py +780 -0
- soorma/cli/commands/init.py +717 -0
- soorma/cli/main.py +52 -0
- soorma/context.py +832 -0
- soorma/events.py +496 -0
- soorma/models.py +24 -0
- soorma/registry/client.py +186 -0
- soorma/utils/schema_utils.py +209 -0
- soorma_core-0.3.0.dist-info/METADATA +454 -0
- soorma_core-0.3.0.dist-info/RECORD +23 -0
- soorma_core-0.3.0.dist-info/WHEEL +4 -0
- soorma_core-0.3.0.dist-info/entry_points.txt +3 -0
- soorma_core-0.3.0.dist-info/licenses/LICENSE.txt +21 -0
soorma/events.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event Client for the Soorma SDK.
|
|
3
|
+
|
|
4
|
+
This module provides an EventClient class that connects to the Soorma Event Service
|
|
5
|
+
using Server-Sent Events (SSE) for real-time event streaming and HTTP for publishing.
|
|
6
|
+
|
|
7
|
+
The SDK abstracts away the underlying message bus (NATS, Kafka, etc.), providing
|
|
8
|
+
a clean interface for agents to publish and subscribe to events.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from soorma.events import EventClient
|
|
12
|
+
|
|
13
|
+
# Create client
|
|
14
|
+
client = EventClient(
|
|
15
|
+
event_service_url="http://localhost:8082",
|
|
16
|
+
agent_id="my-agent",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Define event handlers
|
|
20
|
+
@client.on_event("research.requested")
|
|
21
|
+
async def handle_research(event):
|
|
22
|
+
print(f"Got research request: {event['data']}")
|
|
23
|
+
|
|
24
|
+
# Connect and start receiving events
|
|
25
|
+
await client.connect(topics=["research.*", "action-requests"])
|
|
26
|
+
|
|
27
|
+
# Publish events
|
|
28
|
+
await client.publish(
|
|
29
|
+
event_type="research.completed",
|
|
30
|
+
topic="action-results",
|
|
31
|
+
data={"result": "Analysis complete"},
|
|
32
|
+
)
|
|
33
|
+
"""
|
|
34
|
+
import asyncio
|
|
35
|
+
import json
|
|
36
|
+
import logging
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
|
39
|
+
from uuid import uuid4
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# Type alias for event handlers
|
|
44
|
+
EventHandler = Callable[[Dict[str, Any]], Awaitable[None]]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EventClient:
|
|
48
|
+
"""
|
|
49
|
+
Client for publishing and subscribing to events via the Soorma Event Service.
|
|
50
|
+
|
|
51
|
+
This client:
|
|
52
|
+
- Uses HTTP POST for publishing events
|
|
53
|
+
- Uses SSE (Server-Sent Events) for real-time event subscription
|
|
54
|
+
- Handles auto-reconnection with exponential backoff
|
|
55
|
+
- Dispatches events to registered handlers
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
event_service_url: Base URL of the Event Service
|
|
59
|
+
agent_id: Unique identifier for this agent
|
|
60
|
+
source: Source identifier for published events (defaults to agent_id)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
event_service_url: str = "http://localhost:8082",
|
|
66
|
+
agent_id: Optional[str] = None,
|
|
67
|
+
source: Optional[str] = None,
|
|
68
|
+
tenant_id: Optional[str] = None,
|
|
69
|
+
session_id: Optional[str] = None,
|
|
70
|
+
max_reconnect_attempts: int = -1, # -1 = infinite
|
|
71
|
+
reconnect_base_delay: float = 1.0,
|
|
72
|
+
reconnect_max_delay: float = 60.0,
|
|
73
|
+
):
|
|
74
|
+
"""
|
|
75
|
+
Initialize the EventClient.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
event_service_url: Base URL of the Event Service (e.g., "http://localhost:8082")
|
|
79
|
+
agent_id: Unique identifier for this agent (auto-generated if not provided)
|
|
80
|
+
source: Source identifier for events (defaults to agent_id)
|
|
81
|
+
tenant_id: Default tenant ID for multi-tenancy
|
|
82
|
+
session_id: Default session ID for correlation
|
|
83
|
+
max_reconnect_attempts: Max reconnection attempts (-1 for infinite)
|
|
84
|
+
reconnect_base_delay: Initial delay between reconnection attempts (seconds)
|
|
85
|
+
reconnect_max_delay: Maximum delay between reconnection attempts (seconds)
|
|
86
|
+
"""
|
|
87
|
+
self.event_service_url = event_service_url.rstrip("/")
|
|
88
|
+
self.agent_id = agent_id or f"agent-{str(uuid4())[:8]}"
|
|
89
|
+
self.source = source or self.agent_id
|
|
90
|
+
self.tenant_id = tenant_id
|
|
91
|
+
self.session_id = session_id
|
|
92
|
+
|
|
93
|
+
# Reconnection settings
|
|
94
|
+
self._max_reconnect_attempts = max_reconnect_attempts
|
|
95
|
+
self._reconnect_base_delay = reconnect_base_delay
|
|
96
|
+
self._reconnect_max_delay = reconnect_max_delay
|
|
97
|
+
|
|
98
|
+
# Connection state
|
|
99
|
+
self._connected = False
|
|
100
|
+
self._connection_id: Optional[str] = None
|
|
101
|
+
self._subscribed_topics: List[str] = []
|
|
102
|
+
self._stream_task: Optional[asyncio.Task] = None
|
|
103
|
+
self._stop_event = asyncio.Event()
|
|
104
|
+
|
|
105
|
+
# Event handlers: event_type -> list of handlers
|
|
106
|
+
self._handlers: Dict[str, List[EventHandler]] = {}
|
|
107
|
+
# Catch-all handlers (receive all events)
|
|
108
|
+
self._catch_all_handlers: List[EventHandler] = []
|
|
109
|
+
|
|
110
|
+
# HTTP client (lazy initialized)
|
|
111
|
+
self._http_client = None
|
|
112
|
+
|
|
113
|
+
# =========================================================================
|
|
114
|
+
# Decorator for registering handlers
|
|
115
|
+
# =========================================================================
|
|
116
|
+
|
|
117
|
+
def on_event(self, event_type: str) -> Callable[[EventHandler], EventHandler]:
|
|
118
|
+
"""
|
|
119
|
+
Decorator to register an event handler for a specific event type.
|
|
120
|
+
|
|
121
|
+
Usage:
|
|
122
|
+
@client.on_event("research.requested")
|
|
123
|
+
async def handle_research(event):
|
|
124
|
+
print(f"Received: {event}")
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
event_type: The event type to handle (e.g., "research.requested")
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Decorator function
|
|
131
|
+
"""
|
|
132
|
+
def decorator(func: EventHandler) -> EventHandler:
|
|
133
|
+
if event_type not in self._handlers:
|
|
134
|
+
self._handlers[event_type] = []
|
|
135
|
+
self._handlers[event_type].append(func)
|
|
136
|
+
logger.debug(f"Registered handler for event type: {event_type}")
|
|
137
|
+
return func
|
|
138
|
+
return decorator
|
|
139
|
+
|
|
140
|
+
def on_all_events(self, func: EventHandler) -> EventHandler:
|
|
141
|
+
"""
|
|
142
|
+
Decorator to register a catch-all event handler.
|
|
143
|
+
|
|
144
|
+
Usage:
|
|
145
|
+
@client.on_all_events
|
|
146
|
+
async def handle_all(event):
|
|
147
|
+
print(f"Received: {event}")
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
func: The handler function
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The handler function
|
|
154
|
+
"""
|
|
155
|
+
self._catch_all_handlers.append(func)
|
|
156
|
+
logger.debug("Registered catch-all handler")
|
|
157
|
+
return func
|
|
158
|
+
|
|
159
|
+
# =========================================================================
|
|
160
|
+
# Connection Management
|
|
161
|
+
# =========================================================================
|
|
162
|
+
|
|
163
|
+
async def connect(self, topics: List[str]) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Connect to the Event Service and start receiving events.
|
|
166
|
+
|
|
167
|
+
This method:
|
|
168
|
+
1. Establishes an SSE connection to the Event Service
|
|
169
|
+
2. Subscribes to the specified topics
|
|
170
|
+
3. Starts a background task to process incoming events
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
topics: List of topic patterns to subscribe to (e.g., ["research.*"])
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
ConnectionError: If unable to connect to the Event Service
|
|
177
|
+
"""
|
|
178
|
+
if self._connected:
|
|
179
|
+
logger.warning("Already connected")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
self._subscribed_topics = topics
|
|
183
|
+
self._stop_event.clear()
|
|
184
|
+
|
|
185
|
+
# Start the SSE stream task
|
|
186
|
+
self._stream_task = asyncio.create_task(self._run_stream())
|
|
187
|
+
|
|
188
|
+
# Wait briefly for connection to establish
|
|
189
|
+
await asyncio.sleep(0.5)
|
|
190
|
+
|
|
191
|
+
if not self._connected:
|
|
192
|
+
logger.warning("Connection not yet established, will retry in background")
|
|
193
|
+
|
|
194
|
+
async def disconnect(self) -> None:
|
|
195
|
+
"""
|
|
196
|
+
Disconnect from the Event Service.
|
|
197
|
+
|
|
198
|
+
This gracefully closes the SSE connection and stops the background task.
|
|
199
|
+
"""
|
|
200
|
+
logger.info("Disconnecting from Event Service")
|
|
201
|
+
|
|
202
|
+
self._stop_event.set()
|
|
203
|
+
|
|
204
|
+
if self._stream_task:
|
|
205
|
+
self._stream_task.cancel()
|
|
206
|
+
try:
|
|
207
|
+
await self._stream_task
|
|
208
|
+
except asyncio.CancelledError:
|
|
209
|
+
pass
|
|
210
|
+
self._stream_task = None
|
|
211
|
+
|
|
212
|
+
if self._http_client:
|
|
213
|
+
await self._http_client.aclose()
|
|
214
|
+
self._http_client = None
|
|
215
|
+
|
|
216
|
+
self._connected = False
|
|
217
|
+
self._connection_id = None
|
|
218
|
+
logger.info("Disconnected from Event Service")
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def is_connected(self) -> bool:
|
|
222
|
+
"""Check if connected to the Event Service."""
|
|
223
|
+
return self._connected
|
|
224
|
+
|
|
225
|
+
# =========================================================================
|
|
226
|
+
# Publishing
|
|
227
|
+
# =========================================================================
|
|
228
|
+
|
|
229
|
+
async def publish(
|
|
230
|
+
self,
|
|
231
|
+
event_type: str,
|
|
232
|
+
topic: str,
|
|
233
|
+
data: Optional[Dict[str, Any]] = None,
|
|
234
|
+
correlation_id: Optional[str] = None,
|
|
235
|
+
subject: Optional[str] = None,
|
|
236
|
+
) -> str:
|
|
237
|
+
"""
|
|
238
|
+
Publish an event to the Event Service.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
event_type: Event type (e.g., "research.completed")
|
|
242
|
+
topic: Target topic (e.g., "action-results")
|
|
243
|
+
data: Event payload data
|
|
244
|
+
correlation_id: Optional correlation ID for tracing
|
|
245
|
+
subject: Optional subject/resource identifier
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
The event ID
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
ConnectionError: If unable to publish the event
|
|
252
|
+
"""
|
|
253
|
+
await self._ensure_http_client()
|
|
254
|
+
|
|
255
|
+
event_id = str(uuid4())
|
|
256
|
+
|
|
257
|
+
event = {
|
|
258
|
+
"id": event_id,
|
|
259
|
+
"source": self.source,
|
|
260
|
+
"specversion": "1.0",
|
|
261
|
+
"type": event_type,
|
|
262
|
+
"topic": topic,
|
|
263
|
+
"time": datetime.now(timezone.utc).isoformat(),
|
|
264
|
+
"data": data or {},
|
|
265
|
+
"correlation_id": correlation_id or str(uuid4()),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# Add optional fields
|
|
269
|
+
if subject:
|
|
270
|
+
event["subject"] = subject
|
|
271
|
+
if self.tenant_id:
|
|
272
|
+
event["tenant_id"] = self.tenant_id
|
|
273
|
+
if self.session_id:
|
|
274
|
+
event["session_id"] = self.session_id
|
|
275
|
+
|
|
276
|
+
url = f"{self.event_service_url}/v1/events/publish"
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
response = await self._http_client.post(
|
|
280
|
+
url,
|
|
281
|
+
json={"event": event},
|
|
282
|
+
timeout=30.0,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if response.status_code != 200:
|
|
286
|
+
error_detail = response.text
|
|
287
|
+
raise ConnectionError(f"Failed to publish event: {response.status_code} - {error_detail}")
|
|
288
|
+
|
|
289
|
+
result = response.json()
|
|
290
|
+
logger.debug(f"Published event {event_id} to {topic}")
|
|
291
|
+
|
|
292
|
+
return result.get("event_id", event_id)
|
|
293
|
+
|
|
294
|
+
except Exception as e:
|
|
295
|
+
logger.error(f"Error publishing event: {e}")
|
|
296
|
+
raise ConnectionError(f"Failed to publish event: {e}") from e
|
|
297
|
+
|
|
298
|
+
# =========================================================================
|
|
299
|
+
# SSE Stream Processing
|
|
300
|
+
# =========================================================================
|
|
301
|
+
|
|
302
|
+
async def _run_stream(self) -> None:
|
|
303
|
+
"""
|
|
304
|
+
Main loop for the SSE stream.
|
|
305
|
+
|
|
306
|
+
Handles connection, reconnection with exponential backoff, and event dispatch.
|
|
307
|
+
"""
|
|
308
|
+
attempt = 0
|
|
309
|
+
|
|
310
|
+
while not self._stop_event.is_set():
|
|
311
|
+
try:
|
|
312
|
+
await self._stream_events()
|
|
313
|
+
# If we get here, connection closed gracefully
|
|
314
|
+
if self._stop_event.is_set():
|
|
315
|
+
break
|
|
316
|
+
attempt = 0 # Reset on successful connection
|
|
317
|
+
|
|
318
|
+
except asyncio.CancelledError:
|
|
319
|
+
break
|
|
320
|
+
except Exception as e:
|
|
321
|
+
self._connected = False
|
|
322
|
+
|
|
323
|
+
# Only log if there's meaningful error info
|
|
324
|
+
error_msg = str(e).strip()
|
|
325
|
+
if error_msg:
|
|
326
|
+
logger.warning(f"Stream connection issue: {error_msg}")
|
|
327
|
+
else:
|
|
328
|
+
logger.debug("Stream disconnected, reconnecting...")
|
|
329
|
+
|
|
330
|
+
# Check if we should retry
|
|
331
|
+
if self._max_reconnect_attempts >= 0 and attempt >= self._max_reconnect_attempts:
|
|
332
|
+
logger.error("Max reconnection attempts reached")
|
|
333
|
+
break
|
|
334
|
+
|
|
335
|
+
# Calculate backoff delay
|
|
336
|
+
delay = min(
|
|
337
|
+
self._reconnect_base_delay * (2 ** attempt),
|
|
338
|
+
self._reconnect_max_delay,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Only log reconnection on first few attempts to reduce noise
|
|
342
|
+
if attempt < 3:
|
|
343
|
+
logger.debug(f"Reconnecting in {delay:.1f}s (attempt {attempt + 1})")
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
await asyncio.wait_for(
|
|
347
|
+
self._stop_event.wait(),
|
|
348
|
+
timeout=delay,
|
|
349
|
+
)
|
|
350
|
+
break # Stop event was set
|
|
351
|
+
except asyncio.TimeoutError:
|
|
352
|
+
pass # Continue with retry
|
|
353
|
+
|
|
354
|
+
attempt += 1
|
|
355
|
+
|
|
356
|
+
async def _stream_events(self) -> None:
|
|
357
|
+
"""
|
|
358
|
+
Connect to SSE stream and process events.
|
|
359
|
+
"""
|
|
360
|
+
await self._ensure_http_client()
|
|
361
|
+
|
|
362
|
+
topics_param = ",".join(self._subscribed_topics)
|
|
363
|
+
# Pass source as agent_name for load balancing queue groups
|
|
364
|
+
url = f"{self.event_service_url}/v1/events/stream?topics={topics_param}&agent_id={self.agent_id}&agent_name={self.source}"
|
|
365
|
+
|
|
366
|
+
logger.info(f"Connecting to SSE stream: {url}")
|
|
367
|
+
|
|
368
|
+
async with self._http_client.stream("GET", url) as response:
|
|
369
|
+
if response.status_code != 200:
|
|
370
|
+
raise ConnectionError(f"SSE connection failed: {response.status_code}")
|
|
371
|
+
|
|
372
|
+
# Process SSE stream
|
|
373
|
+
event_type = None
|
|
374
|
+
data_lines: List[str] = []
|
|
375
|
+
|
|
376
|
+
async for line in response.aiter_lines():
|
|
377
|
+
if self._stop_event.is_set():
|
|
378
|
+
break
|
|
379
|
+
|
|
380
|
+
line = line.strip()
|
|
381
|
+
|
|
382
|
+
if not line:
|
|
383
|
+
# Empty line = end of event
|
|
384
|
+
if event_type and data_lines:
|
|
385
|
+
data = "\n".join(data_lines)
|
|
386
|
+
await self._handle_sse_event(event_type, data)
|
|
387
|
+
event_type = None
|
|
388
|
+
data_lines = []
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
if line.startswith("event:"):
|
|
392
|
+
event_type = line[6:].strip()
|
|
393
|
+
elif line.startswith("data:"):
|
|
394
|
+
data_lines.append(line[5:].strip())
|
|
395
|
+
# Ignore other fields (id, retry, etc.)
|
|
396
|
+
|
|
397
|
+
async def _handle_sse_event(self, event_type: str, data: str) -> None:
|
|
398
|
+
"""
|
|
399
|
+
Handle an SSE event.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
event_type: SSE event type (e.g., "connected", "message", "heartbeat")
|
|
403
|
+
data: Event data (JSON string)
|
|
404
|
+
"""
|
|
405
|
+
try:
|
|
406
|
+
parsed_data = json.loads(data)
|
|
407
|
+
except json.JSONDecodeError:
|
|
408
|
+
logger.warning(f"Failed to parse SSE data: {data}")
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
if event_type == "connected":
|
|
412
|
+
self._connected = True
|
|
413
|
+
self._connection_id = parsed_data.get("connection_id")
|
|
414
|
+
logger.info(f"Connected to Event Service (connection_id: {self._connection_id})")
|
|
415
|
+
|
|
416
|
+
elif event_type == "heartbeat":
|
|
417
|
+
logger.debug("Received heartbeat")
|
|
418
|
+
|
|
419
|
+
elif event_type == "message":
|
|
420
|
+
# Dispatch to handlers
|
|
421
|
+
await self._dispatch_event(parsed_data)
|
|
422
|
+
|
|
423
|
+
elif event_type == "disconnected":
|
|
424
|
+
self._connected = False
|
|
425
|
+
logger.info("Received disconnect from Event Service")
|
|
426
|
+
|
|
427
|
+
else:
|
|
428
|
+
logger.debug(f"Unknown SSE event type: {event_type}")
|
|
429
|
+
|
|
430
|
+
async def _dispatch_event(self, event: Dict[str, Any]) -> None:
|
|
431
|
+
"""
|
|
432
|
+
Dispatch an event to registered handlers.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
event: The event data
|
|
436
|
+
"""
|
|
437
|
+
event_type = event.get("type", "")
|
|
438
|
+
|
|
439
|
+
# Call specific handlers
|
|
440
|
+
handlers = self._handlers.get(event_type, [])
|
|
441
|
+
for handler in handlers:
|
|
442
|
+
try:
|
|
443
|
+
await handler(event)
|
|
444
|
+
except Exception as e:
|
|
445
|
+
logger.error(f"Error in handler for {event_type}: {e}")
|
|
446
|
+
|
|
447
|
+
# Call catch-all handlers
|
|
448
|
+
for handler in self._catch_all_handlers:
|
|
449
|
+
try:
|
|
450
|
+
await handler(event)
|
|
451
|
+
except Exception as e:
|
|
452
|
+
logger.error(f"Error in catch-all handler: {e}")
|
|
453
|
+
|
|
454
|
+
if not handlers and not self._catch_all_handlers:
|
|
455
|
+
logger.debug(f"No handlers for event type: {event_type}")
|
|
456
|
+
|
|
457
|
+
# =========================================================================
|
|
458
|
+
# HTTP Client Management
|
|
459
|
+
# =========================================================================
|
|
460
|
+
|
|
461
|
+
async def _ensure_http_client(self) -> None:
|
|
462
|
+
"""Ensure HTTP client is initialized."""
|
|
463
|
+
if self._http_client is None:
|
|
464
|
+
try:
|
|
465
|
+
import httpx
|
|
466
|
+
except ImportError:
|
|
467
|
+
raise ImportError(
|
|
468
|
+
"httpx is required for EventClient. "
|
|
469
|
+
"Install it with: pip install httpx"
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Configure timeout for SSE streaming:
|
|
473
|
+
# - connect: 10s for initial connection
|
|
474
|
+
# - read: None (no timeout) for long-lived SSE streams
|
|
475
|
+
# - write: 30s for publishing
|
|
476
|
+
# - pool: None (no timeout) for connection pool
|
|
477
|
+
timeout = httpx.Timeout(
|
|
478
|
+
connect=10.0,
|
|
479
|
+
read=None, # SSE streams are long-lived
|
|
480
|
+
write=30.0,
|
|
481
|
+
pool=None,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
self._http_client = httpx.AsyncClient(timeout=timeout)
|
|
485
|
+
|
|
486
|
+
# =========================================================================
|
|
487
|
+
# Context Manager Support
|
|
488
|
+
# =========================================================================
|
|
489
|
+
|
|
490
|
+
async def __aenter__(self) -> "EventClient":
|
|
491
|
+
"""Async context manager entry."""
|
|
492
|
+
return self
|
|
493
|
+
|
|
494
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
495
|
+
"""Async context manager exit."""
|
|
496
|
+
await self.disconnect()
|
soorma/models.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Soorma SDK Models.
|
|
3
|
+
|
|
4
|
+
This module re-exports common data models used throughout the SDK.
|
|
5
|
+
"""
|
|
6
|
+
from soorma_common import (
|
|
7
|
+
AgentCapability,
|
|
8
|
+
AgentDefinition,
|
|
9
|
+
AgentRegistrationRequest,
|
|
10
|
+
AgentRegistrationResponse,
|
|
11
|
+
AgentQueryResponse,
|
|
12
|
+
EventDefinition,
|
|
13
|
+
BaseDTO
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AgentCapability",
|
|
18
|
+
"AgentDefinition",
|
|
19
|
+
"AgentRegistrationRequest",
|
|
20
|
+
"AgentRegistrationResponse",
|
|
21
|
+
"AgentQueryResponse",
|
|
22
|
+
"EventDefinition",
|
|
23
|
+
"BaseDTO",
|
|
24
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client library for interacting with the Registry Service.
|
|
3
|
+
"""
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
import httpx
|
|
6
|
+
from soorma_common import (
|
|
7
|
+
EventDefinition,
|
|
8
|
+
EventRegistrationRequest,
|
|
9
|
+
EventRegistrationResponse,
|
|
10
|
+
EventQueryResponse,
|
|
11
|
+
AgentDefinition,
|
|
12
|
+
AgentRegistrationRequest,
|
|
13
|
+
AgentRegistrationResponse,
|
|
14
|
+
AgentQueryResponse,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RegistryClient:
|
|
19
|
+
"""
|
|
20
|
+
Client for interacting with the Registry Service API.
|
|
21
|
+
|
|
22
|
+
This client allows other services to register and query events and agents.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, base_url: str, timeout: float = 30.0):
|
|
26
|
+
"""
|
|
27
|
+
Initialize the registry client.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
base_url: Base URL of the registry service (e.g., "http://localhost:8000")
|
|
31
|
+
timeout: HTTP request timeout in seconds
|
|
32
|
+
"""
|
|
33
|
+
self.base_url = base_url.rstrip("/")
|
|
34
|
+
self.timeout = timeout
|
|
35
|
+
self._client = httpx.AsyncClient(timeout=timeout)
|
|
36
|
+
|
|
37
|
+
async def close(self):
|
|
38
|
+
"""Close the HTTP client."""
|
|
39
|
+
await self._client.aclose()
|
|
40
|
+
|
|
41
|
+
async def __aenter__(self):
|
|
42
|
+
"""Async context manager entry."""
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
46
|
+
"""Async context manager exit."""
|
|
47
|
+
await self.close()
|
|
48
|
+
|
|
49
|
+
# Event Registry Methods
|
|
50
|
+
|
|
51
|
+
async def register_event(self, event: EventDefinition) -> EventRegistrationResponse:
|
|
52
|
+
"""
|
|
53
|
+
Register a new event in the event registry.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
event: Event definition to register
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
EventRegistrationResponse with registration status
|
|
60
|
+
"""
|
|
61
|
+
request = EventRegistrationRequest(event=event)
|
|
62
|
+
response = await self._client.post(
|
|
63
|
+
f"{self.base_url}/api/v1/events",
|
|
64
|
+
json=request.model_dump(by_alias=True)
|
|
65
|
+
)
|
|
66
|
+
response.raise_for_status()
|
|
67
|
+
return EventRegistrationResponse.model_validate(response.json())
|
|
68
|
+
|
|
69
|
+
async def get_event(self, event_name: str) -> Optional[EventDefinition]:
|
|
70
|
+
"""
|
|
71
|
+
Get a specific event by name.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
event_name: Name of the event to retrieve
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
EventDefinition if found, None otherwise
|
|
78
|
+
"""
|
|
79
|
+
response = await self._client.get(
|
|
80
|
+
f"{self.base_url}/api/v1/events",
|
|
81
|
+
params={"event_name": event_name}
|
|
82
|
+
)
|
|
83
|
+
response.raise_for_status()
|
|
84
|
+
result = EventQueryResponse.model_validate(response.json())
|
|
85
|
+
return result.events[0] if result.events else None
|
|
86
|
+
|
|
87
|
+
async def get_events_by_topic(self, topic: str) -> List[EventDefinition]:
|
|
88
|
+
"""
|
|
89
|
+
Get all events for a specific topic.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
topic: Topic to filter by
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of EventDefinitions for the topic
|
|
96
|
+
"""
|
|
97
|
+
response = await self._client.get(
|
|
98
|
+
f"{self.base_url}/api/v1/events",
|
|
99
|
+
params={"topic": topic}
|
|
100
|
+
)
|
|
101
|
+
response.raise_for_status()
|
|
102
|
+
result = EventQueryResponse.model_validate(response.json())
|
|
103
|
+
return result.events
|
|
104
|
+
|
|
105
|
+
async def get_all_events(self) -> List[EventDefinition]:
|
|
106
|
+
"""
|
|
107
|
+
Get all registered events.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
List of all EventDefinitions
|
|
111
|
+
"""
|
|
112
|
+
response = await self._client.get(f"{self.base_url}/api/v1/events")
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
result = EventQueryResponse.model_validate(response.json())
|
|
115
|
+
return result.events
|
|
116
|
+
|
|
117
|
+
# Agent Registry Methods
|
|
118
|
+
|
|
119
|
+
async def register_agent(self, agent: AgentDefinition) -> AgentRegistrationResponse:
|
|
120
|
+
"""
|
|
121
|
+
Register a new agent in the agent registry.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
agent: Agent definition to register
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
AgentRegistrationResponse with registration status
|
|
128
|
+
"""
|
|
129
|
+
request = AgentRegistrationRequest(agent=agent)
|
|
130
|
+
response = await self._client.post(
|
|
131
|
+
f"{self.base_url}/api/v1/agents",
|
|
132
|
+
json=request.model_dump(by_alias=True)
|
|
133
|
+
)
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
return AgentRegistrationResponse.model_validate(response.json())
|
|
136
|
+
|
|
137
|
+
async def get_agent(self, agent_id: str) -> Optional[AgentDefinition]:
|
|
138
|
+
"""
|
|
139
|
+
Get a specific agent by ID.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
agent_id: ID of the agent to retrieve
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
AgentDefinition if found, None otherwise
|
|
146
|
+
"""
|
|
147
|
+
response = await self._client.get(
|
|
148
|
+
f"{self.base_url}/api/v1/agents",
|
|
149
|
+
params={"agent_id": agent_id}
|
|
150
|
+
)
|
|
151
|
+
response.raise_for_status()
|
|
152
|
+
result = AgentQueryResponse.model_validate(response.json())
|
|
153
|
+
return result.agents[0] if result.agents else None
|
|
154
|
+
|
|
155
|
+
async def query_agents(
|
|
156
|
+
self,
|
|
157
|
+
name: Optional[str] = None,
|
|
158
|
+
consumed_event: Optional[str] = None,
|
|
159
|
+
produced_event: Optional[str] = None
|
|
160
|
+
) -> List[AgentDefinition]:
|
|
161
|
+
"""
|
|
162
|
+
Query agents with filters.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
name: Filter by agent name
|
|
166
|
+
consumed_event: Filter by consumed event
|
|
167
|
+
produced_event: Filter by produced event
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List of matching AgentDefinitions
|
|
171
|
+
"""
|
|
172
|
+
params = {}
|
|
173
|
+
if name:
|
|
174
|
+
params["name"] = name
|
|
175
|
+
if consumed_event:
|
|
176
|
+
params["consumed_event"] = consumed_event
|
|
177
|
+
if produced_event:
|
|
178
|
+
params["produced_event"] = produced_event
|
|
179
|
+
|
|
180
|
+
response = await self._client.get(
|
|
181
|
+
f"{self.base_url}/api/v1/agents",
|
|
182
|
+
params=params
|
|
183
|
+
)
|
|
184
|
+
response.raise_for_status()
|
|
185
|
+
result = AgentQueryResponse.model_validate(response.json())
|
|
186
|
+
return result.agents
|