jarviscore-framework 0.3.1__py3-none-any.whl → 0.3.2__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.
- examples/customagent_cognitive_discovery_example.py +49 -8
- examples/customagent_distributed_example.py +140 -1
- examples/fastapi_integration_example.py +70 -7
- jarviscore/__init__.py +1 -1
- jarviscore/core/mesh.py +149 -0
- jarviscore/data/examples/customagent_cognitive_discovery_example.py +49 -8
- jarviscore/data/examples/customagent_distributed_example.py +140 -1
- jarviscore/data/examples/fastapi_integration_example.py +70 -7
- jarviscore/docs/API_REFERENCE.md +547 -5
- jarviscore/docs/CHANGELOG.md +89 -0
- jarviscore/docs/CONFIGURATION.md +1 -1
- jarviscore/docs/CUSTOMAGENT_GUIDE.md +347 -2
- jarviscore/docs/TROUBLESHOOTING.md +1 -1
- jarviscore/docs/USER_GUIDE.md +286 -5
- jarviscore/p2p/coordinator.py +36 -7
- jarviscore/p2p/messages.py +13 -0
- jarviscore/p2p/peer_client.py +355 -23
- jarviscore/p2p/peer_tool.py +17 -11
- jarviscore/profiles/customagent.py +9 -2
- jarviscore/testing/__init__.py +35 -0
- jarviscore/testing/mocks.py +578 -0
- {jarviscore_framework-0.3.1.dist-info → jarviscore_framework-0.3.2.dist-info}/METADATA +2 -2
- {jarviscore_framework-0.3.1.dist-info → jarviscore_framework-0.3.2.dist-info}/RECORD +31 -24
- tests/test_17_session_context.py +489 -0
- tests/test_18_mesh_diagnostics.py +465 -0
- tests/test_19_async_requests.py +516 -0
- tests/test_20_load_balancing.py +546 -0
- tests/test_21_mock_testing.py +776 -0
- {jarviscore_framework-0.3.1.dist-info → jarviscore_framework-0.3.2.dist-info}/WHEEL +0 -0
- {jarviscore_framework-0.3.1.dist-info → jarviscore_framework-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {jarviscore_framework-0.3.1.dist-info → jarviscore_framework-0.3.2.dist-info}/top_level.txt +0 -0
jarviscore/p2p/coordinator.py
CHANGED
|
@@ -702,7 +702,8 @@ class P2PCoordinator:
|
|
|
702
702
|
type=MessageType.NOTIFY,
|
|
703
703
|
data=payload.get('data', {}),
|
|
704
704
|
correlation_id=payload.get('correlation_id'),
|
|
705
|
-
timestamp=payload.get('timestamp', 0)
|
|
705
|
+
timestamp=payload.get('timestamp', 0),
|
|
706
|
+
context=payload.get('context')
|
|
706
707
|
)
|
|
707
708
|
|
|
708
709
|
await target_client._deliver_message(incoming)
|
|
@@ -714,15 +715,34 @@ class P2PCoordinator:
|
|
|
714
715
|
async def _handle_peer_request(self, sender, message):
|
|
715
716
|
"""Handle peer request message (expects response)."""
|
|
716
717
|
try:
|
|
717
|
-
|
|
718
|
+
logger.info(f"[COORDINATOR] Received PEER_REQUEST from {sender}")
|
|
719
|
+
|
|
720
|
+
# Parse payload - it comes as JSON string in message['payload']
|
|
721
|
+
import json
|
|
722
|
+
payload_raw = message.get('payload', {})
|
|
723
|
+
if isinstance(payload_raw, str):
|
|
724
|
+
try:
|
|
725
|
+
payload = json.loads(payload_raw)
|
|
726
|
+
logger.info(f"[COORDINATOR] Parsed JSON payload")
|
|
727
|
+
except json.JSONDecodeError as e:
|
|
728
|
+
logger.error(f"[COORDINATOR] Failed to parse payload JSON: {e}")
|
|
729
|
+
return
|
|
730
|
+
else:
|
|
731
|
+
payload = payload_raw
|
|
732
|
+
|
|
718
733
|
target = payload.get('target')
|
|
734
|
+
logger.info(f"[COORDINATOR] Target: {target}, Payload keys: {list(payload.keys())}")
|
|
719
735
|
|
|
720
736
|
# Find target agent's PeerClient
|
|
721
737
|
target_client = self._find_peer_client_by_role_or_id(target)
|
|
722
738
|
if not target_client:
|
|
723
739
|
logger.warning(f"Peer request: target '{target}' not found")
|
|
740
|
+
logger.warning(f"Available agents: {[a.agent_id for a in self.agents]}")
|
|
741
|
+
logger.warning(f"Available peer clients: {list(self._agent_peer_clients.keys())}")
|
|
724
742
|
return
|
|
725
743
|
|
|
744
|
+
logger.info(f"[COORDINATOR] Found target_client for {target}, delivering message...")
|
|
745
|
+
|
|
726
746
|
# Create incoming message and deliver
|
|
727
747
|
incoming = IncomingMessage(
|
|
728
748
|
sender=payload.get('sender', sender),
|
|
@@ -730,19 +750,27 @@ class P2PCoordinator:
|
|
|
730
750
|
type=MessageType.REQUEST,
|
|
731
751
|
data=payload.get('data', {}),
|
|
732
752
|
correlation_id=payload.get('correlation_id'),
|
|
733
|
-
timestamp=payload.get('timestamp', 0)
|
|
753
|
+
timestamp=payload.get('timestamp', 0),
|
|
754
|
+
context=payload.get('context')
|
|
734
755
|
)
|
|
735
756
|
|
|
736
757
|
await target_client._deliver_message(incoming)
|
|
737
|
-
logger.
|
|
758
|
+
logger.info(f"[COORDINATOR] Delivered peer request to {target}")
|
|
738
759
|
|
|
739
760
|
except Exception as e:
|
|
740
|
-
logger.error(f"Error handling peer request: {e}")
|
|
761
|
+
logger.error(f"Error handling peer request: {e}", exc_info=True)
|
|
741
762
|
|
|
742
763
|
async def _handle_peer_response(self, sender, message):
|
|
743
764
|
"""Handle peer response message."""
|
|
744
765
|
try:
|
|
745
|
-
|
|
766
|
+
# Parse payload - it comes as JSON string
|
|
767
|
+
import json
|
|
768
|
+
payload_raw = message.get('payload', {})
|
|
769
|
+
if isinstance(payload_raw, str):
|
|
770
|
+
payload = json.loads(payload_raw)
|
|
771
|
+
else:
|
|
772
|
+
payload = payload_raw
|
|
773
|
+
|
|
746
774
|
target = payload.get('target')
|
|
747
775
|
|
|
748
776
|
# Find target agent's PeerClient
|
|
@@ -758,7 +786,8 @@ class P2PCoordinator:
|
|
|
758
786
|
type=MessageType.RESPONSE,
|
|
759
787
|
data=payload.get('data', {}),
|
|
760
788
|
correlation_id=payload.get('correlation_id'),
|
|
761
|
-
timestamp=payload.get('timestamp', 0)
|
|
789
|
+
timestamp=payload.get('timestamp', 0),
|
|
790
|
+
context=payload.get('context')
|
|
762
791
|
)
|
|
763
792
|
|
|
764
793
|
await target_client._deliver_message(incoming)
|
jarviscore/p2p/messages.py
CHANGED
|
@@ -52,6 +52,7 @@ class IncomingMessage:
|
|
|
52
52
|
data: Message payload
|
|
53
53
|
correlation_id: ID linking request to response (for request-response pattern)
|
|
54
54
|
timestamp: When the message was sent
|
|
55
|
+
context: Optional metadata for the message (mission_id, priority, trace_id, etc.)
|
|
55
56
|
"""
|
|
56
57
|
sender: str
|
|
57
58
|
sender_node: str
|
|
@@ -59,6 +60,7 @@ class IncomingMessage:
|
|
|
59
60
|
data: Dict[str, Any]
|
|
60
61
|
correlation_id: Optional[str] = None
|
|
61
62
|
timestamp: float = field(default_factory=time.time)
|
|
63
|
+
context: Optional[Dict[str, Any]] = None
|
|
62
64
|
|
|
63
65
|
@property
|
|
64
66
|
def is_request(self) -> bool:
|
|
@@ -77,6 +79,16 @@ class OutgoingMessage:
|
|
|
77
79
|
A message to be sent to a peer agent.
|
|
78
80
|
|
|
79
81
|
Used internally by PeerClient for message construction.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
target: Target agent role or ID
|
|
85
|
+
type: Message type
|
|
86
|
+
data: Message payload
|
|
87
|
+
correlation_id: ID linking request to response
|
|
88
|
+
timestamp: When the message was created
|
|
89
|
+
sender: Agent ID of sender (filled by PeerClient)
|
|
90
|
+
sender_node: P2P node ID of sender (filled by PeerClient)
|
|
91
|
+
context: Optional metadata for the message (mission_id, priority, trace_id, etc.)
|
|
80
92
|
"""
|
|
81
93
|
target: str # Target agent role or ID
|
|
82
94
|
type: MessageType
|
|
@@ -85,3 +97,4 @@ class OutgoingMessage:
|
|
|
85
97
|
timestamp: float = field(default_factory=time.time)
|
|
86
98
|
sender: str = "" # Filled in by PeerClient
|
|
87
99
|
sender_node: str = "" # Filled in by PeerClient
|
|
100
|
+
context: Optional[Dict[str, Any]] = None
|
jarviscore/p2p/peer_client.py
CHANGED
|
@@ -21,6 +21,8 @@ Example:
|
|
|
21
21
|
"""
|
|
22
22
|
import asyncio
|
|
23
23
|
import logging
|
|
24
|
+
import random
|
|
25
|
+
import time
|
|
24
26
|
from typing import List, Dict, Any, Optional, TYPE_CHECKING
|
|
25
27
|
from uuid import uuid4
|
|
26
28
|
|
|
@@ -95,6 +97,16 @@ class PeerClient:
|
|
|
95
97
|
# Pending requests waiting for responses (correlation_id -> Future)
|
|
96
98
|
self._pending_requests: Dict[str, asyncio.Future] = {}
|
|
97
99
|
|
|
100
|
+
# Async request inbox (correlation_id -> response data or None)
|
|
101
|
+
self._async_inbox: Dict[str, Optional[Dict[str, Any]]] = {}
|
|
102
|
+
|
|
103
|
+
# Async request metadata (correlation_id -> metadata dict)
|
|
104
|
+
self._async_requests: Dict[str, Dict[str, Any]] = {}
|
|
105
|
+
|
|
106
|
+
# Load balancing state
|
|
107
|
+
self._round_robin_index: Dict[str, int] = {} # key -> current index
|
|
108
|
+
self._peer_last_used: Dict[str, float] = {} # agent_id -> timestamp
|
|
109
|
+
|
|
98
110
|
self._logger = logging.getLogger(f"jarviscore.peer_client.{agent_id}")
|
|
99
111
|
|
|
100
112
|
# ─────────────────────────────────────────────────────────────────
|
|
@@ -134,7 +146,8 @@ class PeerClient:
|
|
|
134
146
|
def discover(
|
|
135
147
|
self,
|
|
136
148
|
capability: str = None,
|
|
137
|
-
role: str = None
|
|
149
|
+
role: str = None,
|
|
150
|
+
strategy: str = "first"
|
|
138
151
|
) -> List[PeerInfo]:
|
|
139
152
|
"""
|
|
140
153
|
Discover peers by capability or role.
|
|
@@ -142,14 +155,23 @@ class PeerClient:
|
|
|
142
155
|
Args:
|
|
143
156
|
capability: Filter by capability (e.g., "analysis")
|
|
144
157
|
role: Filter by role (e.g., "analyst")
|
|
158
|
+
strategy: Selection strategy for ordering results:
|
|
159
|
+
- "first": Return in discovery order (default, current behavior)
|
|
160
|
+
- "random": Shuffle results randomly
|
|
161
|
+
- "round_robin": Rotate through peers on each call
|
|
162
|
+
- "least_recent": Return least recently used peers first
|
|
145
163
|
|
|
146
164
|
Returns:
|
|
147
|
-
List of matching PeerInfo objects
|
|
165
|
+
List of matching PeerInfo objects, ordered by strategy
|
|
148
166
|
|
|
149
167
|
Example:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
168
|
+
# Get a random analyst for load balancing
|
|
169
|
+
analysts = self.peers.discover(role="analyst", strategy="random")
|
|
170
|
+
if analysts:
|
|
171
|
+
await self.peers.request(analysts[0].role, {"task": "..."})
|
|
172
|
+
|
|
173
|
+
# Round-robin across workers
|
|
174
|
+
workers = self.peers.discover(capability="processing", strategy="round_robin")
|
|
153
175
|
"""
|
|
154
176
|
results = []
|
|
155
177
|
|
|
@@ -197,19 +219,19 @@ class PeerClient:
|
|
|
197
219
|
# Access coordinator's _remote_agent_registry
|
|
198
220
|
if self._coordinator and hasattr(self._coordinator, '_remote_agent_registry'):
|
|
199
221
|
remote_registry = self._coordinator._remote_agent_registry
|
|
200
|
-
|
|
222
|
+
|
|
201
223
|
for agent_id, info in remote_registry.items():
|
|
202
224
|
if agent_id == self._agent_id: # Exclude self
|
|
203
225
|
continue
|
|
204
|
-
|
|
226
|
+
|
|
205
227
|
# Filter by role if specified
|
|
206
228
|
if role and info.get('role') != role:
|
|
207
229
|
continue
|
|
208
|
-
|
|
230
|
+
|
|
209
231
|
# Filter by capability if specified
|
|
210
232
|
if capability and capability not in info.get('capabilities', []):
|
|
211
233
|
continue
|
|
212
|
-
|
|
234
|
+
|
|
213
235
|
# Add remote peer
|
|
214
236
|
results.append(PeerInfo(
|
|
215
237
|
agent_id=info['agent_id'],
|
|
@@ -219,8 +241,86 @@ class PeerClient:
|
|
|
219
241
|
status="alive"
|
|
220
242
|
))
|
|
221
243
|
|
|
244
|
+
# Apply selection strategy
|
|
245
|
+
if results and strategy != "first":
|
|
246
|
+
key = capability or role or "all"
|
|
247
|
+
results = self._apply_strategy(results, key, strategy)
|
|
248
|
+
|
|
222
249
|
return results
|
|
223
250
|
|
|
251
|
+
def _apply_strategy(
|
|
252
|
+
self,
|
|
253
|
+
peers: List[PeerInfo],
|
|
254
|
+
key: str,
|
|
255
|
+
strategy: str
|
|
256
|
+
) -> List[PeerInfo]:
|
|
257
|
+
"""Apply selection strategy to reorder peer list."""
|
|
258
|
+
if len(peers) <= 1:
|
|
259
|
+
return peers
|
|
260
|
+
|
|
261
|
+
if strategy == "random":
|
|
262
|
+
shuffled = peers.copy()
|
|
263
|
+
random.shuffle(shuffled)
|
|
264
|
+
return shuffled
|
|
265
|
+
|
|
266
|
+
elif strategy == "round_robin":
|
|
267
|
+
# Get current index for this key
|
|
268
|
+
idx = self._round_robin_index.get(key, 0)
|
|
269
|
+
# Rotate list to start from current index
|
|
270
|
+
rotated = peers[idx:] + peers[:idx]
|
|
271
|
+
# Increment index for next call
|
|
272
|
+
self._round_robin_index[key] = (idx + 1) % len(peers)
|
|
273
|
+
return rotated
|
|
274
|
+
|
|
275
|
+
elif strategy == "least_recent":
|
|
276
|
+
# Sort by last used time (oldest first)
|
|
277
|
+
def get_last_used(peer: PeerInfo) -> float:
|
|
278
|
+
return self._peer_last_used.get(peer.agent_id, 0.0)
|
|
279
|
+
return sorted(peers, key=get_last_used)
|
|
280
|
+
|
|
281
|
+
else: # "first" or unknown
|
|
282
|
+
return peers
|
|
283
|
+
|
|
284
|
+
def discover_one(
|
|
285
|
+
self,
|
|
286
|
+
capability: str = None,
|
|
287
|
+
role: str = None,
|
|
288
|
+
strategy: str = "first"
|
|
289
|
+
) -> Optional[PeerInfo]:
|
|
290
|
+
"""
|
|
291
|
+
Discover a single peer by capability or role.
|
|
292
|
+
|
|
293
|
+
Convenience method that returns just the first peer from discover().
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
capability: Filter by capability
|
|
297
|
+
role: Filter by role
|
|
298
|
+
strategy: Selection strategy ("first", "random", "round_robin", "least_recent")
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Single PeerInfo or None if no match found
|
|
302
|
+
|
|
303
|
+
Example:
|
|
304
|
+
# Get a random analyst
|
|
305
|
+
analyst = self.peers.discover_one(role="analyst", strategy="random")
|
|
306
|
+
if analyst:
|
|
307
|
+
response = await self.peers.request(analyst.agent_id, {...})
|
|
308
|
+
"""
|
|
309
|
+
peers = self.discover(capability=capability, role=role, strategy=strategy)
|
|
310
|
+
return peers[0] if peers else None
|
|
311
|
+
|
|
312
|
+
def record_peer_usage(self, peer_id: str):
|
|
313
|
+
"""
|
|
314
|
+
Record that a peer was used (for least_recent strategy).
|
|
315
|
+
|
|
316
|
+
Call this after successfully communicating with a peer
|
|
317
|
+
to update the usage timestamp for load balancing.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
peer_id: The agent_id of the peer that was used
|
|
321
|
+
"""
|
|
322
|
+
self._peer_last_used[peer_id] = time.time()
|
|
323
|
+
|
|
224
324
|
@property
|
|
225
325
|
def registry(self) -> Dict[str, PeerInfo]:
|
|
226
326
|
"""
|
|
@@ -337,13 +437,19 @@ class PeerClient:
|
|
|
337
437
|
# MESSAGING - SEND
|
|
338
438
|
# ─────────────────────────────────────────────────────────────────
|
|
339
439
|
|
|
340
|
-
async def notify(
|
|
440
|
+
async def notify(
|
|
441
|
+
self,
|
|
442
|
+
target: str,
|
|
443
|
+
message: Dict[str, Any],
|
|
444
|
+
context: Optional[Dict[str, Any]] = None
|
|
445
|
+
) -> bool:
|
|
341
446
|
"""
|
|
342
447
|
Send a fire-and-forget notification to a peer.
|
|
343
448
|
|
|
344
449
|
Args:
|
|
345
450
|
target: Target agent role (e.g., "analyst") or agent_id
|
|
346
451
|
message: Message payload (any JSON-serializable dict)
|
|
452
|
+
context: Optional metadata (mission_id, priority, trace_id, etc.)
|
|
347
453
|
|
|
348
454
|
Returns:
|
|
349
455
|
True if message was sent successfully
|
|
@@ -352,7 +458,7 @@ class PeerClient:
|
|
|
352
458
|
await self.peers.notify("analyst", {
|
|
353
459
|
"event": "scouting_complete",
|
|
354
460
|
"data": {"findings": 42}
|
|
355
|
-
})
|
|
461
|
+
}, context={"mission_id": "abc123"})
|
|
356
462
|
"""
|
|
357
463
|
target_agent = self._resolve_target(target)
|
|
358
464
|
if not target_agent:
|
|
@@ -364,7 +470,8 @@ class PeerClient:
|
|
|
364
470
|
type=MessageType.NOTIFY,
|
|
365
471
|
data=message,
|
|
366
472
|
sender=self._agent_id,
|
|
367
|
-
sender_node=self._node_id
|
|
473
|
+
sender_node=self._node_id,
|
|
474
|
+
context=context
|
|
368
475
|
)
|
|
369
476
|
|
|
370
477
|
return await self._send_message(target_agent, outgoing)
|
|
@@ -373,7 +480,8 @@ class PeerClient:
|
|
|
373
480
|
self,
|
|
374
481
|
target: str,
|
|
375
482
|
message: Dict[str, Any],
|
|
376
|
-
timeout: float = 30.0
|
|
483
|
+
timeout: float = 30.0,
|
|
484
|
+
context: Optional[Dict[str, Any]] = None
|
|
377
485
|
) -> Optional[Dict[str, Any]]:
|
|
378
486
|
"""
|
|
379
487
|
Send a request and wait for a response.
|
|
@@ -382,6 +490,7 @@ class PeerClient:
|
|
|
382
490
|
target: Target agent role (e.g., "scout") or agent_id
|
|
383
491
|
message: Request payload
|
|
384
492
|
timeout: Max seconds to wait for response (default: 30)
|
|
493
|
+
context: Optional metadata (mission_id, priority, trace_id, etc.)
|
|
385
494
|
|
|
386
495
|
Returns:
|
|
387
496
|
Response data dict, or None if timeout/failure
|
|
@@ -390,7 +499,7 @@ class PeerClient:
|
|
|
390
499
|
response = await self.peers.request("scout", {
|
|
391
500
|
"need": "clarification",
|
|
392
501
|
"entity": "Entity_X"
|
|
393
|
-
}, timeout=10)
|
|
502
|
+
}, timeout=10, context={"mission_id": "abc123", "priority": "high"})
|
|
394
503
|
|
|
395
504
|
if response:
|
|
396
505
|
print(f"Got clarification: {response}")
|
|
@@ -409,7 +518,8 @@ class PeerClient:
|
|
|
409
518
|
data=message,
|
|
410
519
|
correlation_id=correlation_id,
|
|
411
520
|
sender=self._agent_id,
|
|
412
|
-
sender_node=self._node_id
|
|
521
|
+
sender_node=self._node_id,
|
|
522
|
+
context=context
|
|
413
523
|
)
|
|
414
524
|
|
|
415
525
|
# Create future to wait for response
|
|
@@ -434,13 +544,20 @@ class PeerClient:
|
|
|
434
544
|
# Cleanup pending request
|
|
435
545
|
self._pending_requests.pop(correlation_id, None)
|
|
436
546
|
|
|
437
|
-
async def respond(
|
|
547
|
+
async def respond(
|
|
548
|
+
self,
|
|
549
|
+
message: IncomingMessage,
|
|
550
|
+
response: Dict[str, Any],
|
|
551
|
+
context: Optional[Dict[str, Any]] = None
|
|
552
|
+
) -> bool:
|
|
438
553
|
"""
|
|
439
554
|
Respond to an incoming request.
|
|
440
555
|
|
|
441
556
|
Args:
|
|
442
557
|
message: The incoming request message
|
|
443
558
|
response: Response data to send back
|
|
559
|
+
context: Optional metadata to include in response.
|
|
560
|
+
If None, automatically propagates the original request's context.
|
|
444
561
|
|
|
445
562
|
Returns:
|
|
446
563
|
True if response was sent successfully
|
|
@@ -448,7 +565,12 @@ class PeerClient:
|
|
|
448
565
|
Example:
|
|
449
566
|
message = await self.peers.receive()
|
|
450
567
|
if message and message.is_request:
|
|
568
|
+
# Context is auto-propagated from the request
|
|
451
569
|
await self.peers.respond(message, {"result": "done"})
|
|
570
|
+
|
|
571
|
+
# Or override with custom context
|
|
572
|
+
await self.peers.respond(message, {"result": "done"},
|
|
573
|
+
context={"status": "completed"})
|
|
452
574
|
"""
|
|
453
575
|
if not message.correlation_id:
|
|
454
576
|
self._logger.warning("Cannot respond: message has no correlation_id")
|
|
@@ -460,23 +582,32 @@ class PeerClient:
|
|
|
460
582
|
self._logger.warning(f"Cannot respond: sender '{message.sender}' not found")
|
|
461
583
|
return False
|
|
462
584
|
|
|
585
|
+
# Auto-propagate context from request if not overridden
|
|
586
|
+
response_context = context if context is not None else message.context
|
|
587
|
+
|
|
463
588
|
outgoing = OutgoingMessage(
|
|
464
589
|
target=message.sender,
|
|
465
590
|
type=MessageType.RESPONSE,
|
|
466
591
|
data=response,
|
|
467
592
|
correlation_id=message.correlation_id,
|
|
468
593
|
sender=self._agent_id,
|
|
469
|
-
sender_node=self._node_id
|
|
594
|
+
sender_node=self._node_id,
|
|
595
|
+
context=response_context
|
|
470
596
|
)
|
|
471
597
|
|
|
472
598
|
return await self._send_message(target_agent, outgoing)
|
|
473
599
|
|
|
474
|
-
async def broadcast(
|
|
600
|
+
async def broadcast(
|
|
601
|
+
self,
|
|
602
|
+
message: Dict[str, Any],
|
|
603
|
+
context: Optional[Dict[str, Any]] = None
|
|
604
|
+
) -> int:
|
|
475
605
|
"""
|
|
476
606
|
Broadcast notification to ALL peers.
|
|
477
607
|
|
|
478
608
|
Args:
|
|
479
609
|
message: Message payload to broadcast
|
|
610
|
+
context: Optional metadata (mission_id, priority, trace_id, etc.)
|
|
480
611
|
|
|
481
612
|
Returns:
|
|
482
613
|
Number of peers successfully notified
|
|
@@ -485,15 +616,213 @@ class PeerClient:
|
|
|
485
616
|
count = await self.peers.broadcast({
|
|
486
617
|
"event": "status_update",
|
|
487
618
|
"status": "completed"
|
|
488
|
-
})
|
|
619
|
+
}, context={"mission_id": "abc123"})
|
|
489
620
|
print(f"Notified {count} peers")
|
|
490
621
|
"""
|
|
491
622
|
count = 0
|
|
492
623
|
for peer in self.discover():
|
|
493
|
-
if await self.notify(peer.role, message):
|
|
624
|
+
if await self.notify(peer.role, message, context=context):
|
|
494
625
|
count += 1
|
|
495
626
|
return count
|
|
496
627
|
|
|
628
|
+
# ─────────────────────────────────────────────────────────────────
|
|
629
|
+
# ASYNC REQUEST PATTERN
|
|
630
|
+
# ─────────────────────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
async def ask_async(
|
|
633
|
+
self,
|
|
634
|
+
target: str,
|
|
635
|
+
message: Dict[str, Any],
|
|
636
|
+
timeout: float = 120.0,
|
|
637
|
+
context: Optional[Dict[str, Any]] = None
|
|
638
|
+
) -> str:
|
|
639
|
+
"""
|
|
640
|
+
Send a request asynchronously without blocking for response.
|
|
641
|
+
|
|
642
|
+
Unlike request(), this method returns immediately with a request_id.
|
|
643
|
+
Use check_inbox() to retrieve the response later.
|
|
644
|
+
|
|
645
|
+
Args:
|
|
646
|
+
target: Target agent role or agent_id
|
|
647
|
+
message: Request payload
|
|
648
|
+
timeout: Max time to keep request active (default: 120s)
|
|
649
|
+
context: Optional context metadata
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
Request ID to use with check_inbox()
|
|
653
|
+
|
|
654
|
+
Raises:
|
|
655
|
+
ValueError: If target not found or send fails
|
|
656
|
+
|
|
657
|
+
Example:
|
|
658
|
+
# Fire off multiple requests in parallel
|
|
659
|
+
request_ids = []
|
|
660
|
+
for analyst in analysts:
|
|
661
|
+
req_id = await self.peers.ask_async(analyst, {"question": "..."})
|
|
662
|
+
request_ids.append(req_id)
|
|
663
|
+
|
|
664
|
+
# Do other work...
|
|
665
|
+
await process_other_tasks()
|
|
666
|
+
|
|
667
|
+
# Collect responses later
|
|
668
|
+
for req_id in request_ids:
|
|
669
|
+
response = await self.peers.check_inbox(req_id, timeout=5)
|
|
670
|
+
if response:
|
|
671
|
+
results.append(response)
|
|
672
|
+
"""
|
|
673
|
+
target_agent = self._resolve_target(target)
|
|
674
|
+
if not target_agent:
|
|
675
|
+
raise ValueError(f"No peer found for target: {target}")
|
|
676
|
+
|
|
677
|
+
# Generate correlation ID
|
|
678
|
+
correlation_id = f"async-{uuid4().hex[:12]}"
|
|
679
|
+
|
|
680
|
+
# Create outgoing message
|
|
681
|
+
outgoing = OutgoingMessage(
|
|
682
|
+
target=target,
|
|
683
|
+
type=MessageType.REQUEST,
|
|
684
|
+
data=message,
|
|
685
|
+
correlation_id=correlation_id,
|
|
686
|
+
sender=self._agent_id,
|
|
687
|
+
sender_node=self._node_id,
|
|
688
|
+
context=context
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
# Initialize inbox slot
|
|
692
|
+
self._async_inbox[correlation_id] = None
|
|
693
|
+
self._async_requests[correlation_id] = {
|
|
694
|
+
'target': target,
|
|
695
|
+
'sent_at': time.time(),
|
|
696
|
+
'timeout': timeout
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
# Create future for async response handling
|
|
700
|
+
response_future: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
701
|
+
self._pending_requests[correlation_id] = response_future
|
|
702
|
+
|
|
703
|
+
# Setup background task to move response to inbox
|
|
704
|
+
asyncio.create_task(
|
|
705
|
+
self._async_response_handler(correlation_id, response_future, timeout)
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
# Send request
|
|
709
|
+
sent = await self._send_message(target_agent, outgoing)
|
|
710
|
+
if not sent:
|
|
711
|
+
# Cleanup on send failure
|
|
712
|
+
self._async_inbox.pop(correlation_id, None)
|
|
713
|
+
self._async_requests.pop(correlation_id, None)
|
|
714
|
+
self._pending_requests.pop(correlation_id, None)
|
|
715
|
+
raise ValueError(f"Failed to send async request to {target}")
|
|
716
|
+
|
|
717
|
+
self._logger.debug(f"Sent async request {correlation_id} to {target}")
|
|
718
|
+
return correlation_id
|
|
719
|
+
|
|
720
|
+
async def _async_response_handler(
|
|
721
|
+
self,
|
|
722
|
+
correlation_id: str,
|
|
723
|
+
future: asyncio.Future,
|
|
724
|
+
timeout: float
|
|
725
|
+
):
|
|
726
|
+
"""Background handler that moves response to inbox when received."""
|
|
727
|
+
try:
|
|
728
|
+
response = await asyncio.wait_for(future, timeout=timeout)
|
|
729
|
+
self._async_inbox[correlation_id] = response
|
|
730
|
+
self._logger.debug(f"Async response received for {correlation_id}")
|
|
731
|
+
except asyncio.TimeoutError:
|
|
732
|
+
self._async_inbox[correlation_id] = None # Mark as timed out
|
|
733
|
+
self._logger.debug(f"Async request {correlation_id} timed out")
|
|
734
|
+
except asyncio.CancelledError:
|
|
735
|
+
self._async_inbox.pop(correlation_id, None)
|
|
736
|
+
finally:
|
|
737
|
+
self._pending_requests.pop(correlation_id, None)
|
|
738
|
+
|
|
739
|
+
async def check_inbox(
|
|
740
|
+
self,
|
|
741
|
+
request_id: str,
|
|
742
|
+
timeout: float = 0.0,
|
|
743
|
+
remove: bool = True
|
|
744
|
+
) -> Optional[Dict[str, Any]]:
|
|
745
|
+
"""
|
|
746
|
+
Check for a response to an async request.
|
|
747
|
+
|
|
748
|
+
Args:
|
|
749
|
+
request_id: The ID returned by ask_async()
|
|
750
|
+
timeout: How long to wait for response (0 = don't wait, return immediately)
|
|
751
|
+
remove: Remove from inbox after reading (default: True)
|
|
752
|
+
|
|
753
|
+
Returns:
|
|
754
|
+
Response data dict if available, None if not ready or timed out
|
|
755
|
+
|
|
756
|
+
Example:
|
|
757
|
+
# Non-blocking check
|
|
758
|
+
response = await self.peers.check_inbox(request_id)
|
|
759
|
+
|
|
760
|
+
# Wait up to 5 seconds
|
|
761
|
+
response = await self.peers.check_inbox(request_id, timeout=5)
|
|
762
|
+
"""
|
|
763
|
+
if request_id not in self._async_inbox:
|
|
764
|
+
self._logger.debug(f"Request {request_id} not found in inbox")
|
|
765
|
+
return None
|
|
766
|
+
|
|
767
|
+
# If response already available
|
|
768
|
+
response = self._async_inbox.get(request_id)
|
|
769
|
+
if response is not None:
|
|
770
|
+
if remove:
|
|
771
|
+
self._async_inbox.pop(request_id, None)
|
|
772
|
+
self._async_requests.pop(request_id, None)
|
|
773
|
+
return response
|
|
774
|
+
|
|
775
|
+
# If no timeout, return None immediately
|
|
776
|
+
if timeout <= 0:
|
|
777
|
+
return None
|
|
778
|
+
|
|
779
|
+
# Wait for response with timeout
|
|
780
|
+
start_time = time.time()
|
|
781
|
+
while time.time() - start_time < timeout:
|
|
782
|
+
await asyncio.sleep(0.1) # Check every 100ms
|
|
783
|
+
response = self._async_inbox.get(request_id)
|
|
784
|
+
if response is not None:
|
|
785
|
+
if remove:
|
|
786
|
+
self._async_inbox.pop(request_id, None)
|
|
787
|
+
self._async_requests.pop(request_id, None)
|
|
788
|
+
return response
|
|
789
|
+
|
|
790
|
+
return None
|
|
791
|
+
|
|
792
|
+
def get_pending_async_requests(self) -> List[Dict[str, Any]]:
|
|
793
|
+
"""
|
|
794
|
+
Get list of pending async requests.
|
|
795
|
+
|
|
796
|
+
Returns:
|
|
797
|
+
List of dicts with request_id, target, sent_at, timeout
|
|
798
|
+
|
|
799
|
+
Example:
|
|
800
|
+
pending = self.peers.get_pending_async_requests()
|
|
801
|
+
for req in pending:
|
|
802
|
+
print(f"Waiting for {req['target']} since {req['sent_at']}")
|
|
803
|
+
"""
|
|
804
|
+
return [
|
|
805
|
+
{
|
|
806
|
+
'request_id': req_id,
|
|
807
|
+
**metadata
|
|
808
|
+
}
|
|
809
|
+
for req_id, metadata in self._async_requests.items()
|
|
810
|
+
]
|
|
811
|
+
|
|
812
|
+
def clear_inbox(self, request_id: Optional[str] = None):
|
|
813
|
+
"""
|
|
814
|
+
Clear async request inbox.
|
|
815
|
+
|
|
816
|
+
Args:
|
|
817
|
+
request_id: Specific request to clear, or None to clear all
|
|
818
|
+
"""
|
|
819
|
+
if request_id:
|
|
820
|
+
self._async_inbox.pop(request_id, None)
|
|
821
|
+
self._async_requests.pop(request_id, None)
|
|
822
|
+
else:
|
|
823
|
+
self._async_inbox.clear()
|
|
824
|
+
self._async_requests.clear()
|
|
825
|
+
|
|
497
826
|
# ─────────────────────────────────────────────────────────────────
|
|
498
827
|
# TOOL ADAPTER
|
|
499
828
|
# ─────────────────────────────────────────────────────────────────
|
|
@@ -794,7 +1123,8 @@ class PeerClient:
|
|
|
794
1123
|
'target_role': target_agent.role,
|
|
795
1124
|
'data': message.data,
|
|
796
1125
|
'correlation_id': message.correlation_id,
|
|
797
|
-
'timestamp': message.timestamp
|
|
1126
|
+
'timestamp': message.timestamp,
|
|
1127
|
+
'context': message.context
|
|
798
1128
|
}
|
|
799
1129
|
result = await self._coordinator._send_p2p_message(
|
|
800
1130
|
target_agent.node_id,
|
|
@@ -819,7 +1149,8 @@ class PeerClient:
|
|
|
819
1149
|
type=message.type,
|
|
820
1150
|
data=message.data,
|
|
821
1151
|
correlation_id=message.correlation_id,
|
|
822
|
-
timestamp=message.timestamp
|
|
1152
|
+
timestamp=message.timestamp,
|
|
1153
|
+
context=message.context
|
|
823
1154
|
)
|
|
824
1155
|
await target_agent.peers._deliver_message(incoming)
|
|
825
1156
|
self._logger.debug(
|
|
@@ -836,7 +1167,8 @@ class PeerClient:
|
|
|
836
1167
|
'target': message.target,
|
|
837
1168
|
'data': message.data,
|
|
838
1169
|
'correlation_id': message.correlation_id,
|
|
839
|
-
'timestamp': message.timestamp
|
|
1170
|
+
'timestamp': message.timestamp,
|
|
1171
|
+
'context': message.context
|
|
840
1172
|
}
|
|
841
1173
|
node_id = getattr(target_agent, 'node_id', None) or self._node_id
|
|
842
1174
|
return await self._coordinator._send_p2p_message(
|