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.
Files changed (31) hide show
  1. examples/customagent_cognitive_discovery_example.py +49 -8
  2. examples/customagent_distributed_example.py +140 -1
  3. examples/fastapi_integration_example.py +70 -7
  4. jarviscore/__init__.py +1 -1
  5. jarviscore/core/mesh.py +149 -0
  6. jarviscore/data/examples/customagent_cognitive_discovery_example.py +49 -8
  7. jarviscore/data/examples/customagent_distributed_example.py +140 -1
  8. jarviscore/data/examples/fastapi_integration_example.py +70 -7
  9. jarviscore/docs/API_REFERENCE.md +547 -5
  10. jarviscore/docs/CHANGELOG.md +89 -0
  11. jarviscore/docs/CONFIGURATION.md +1 -1
  12. jarviscore/docs/CUSTOMAGENT_GUIDE.md +347 -2
  13. jarviscore/docs/TROUBLESHOOTING.md +1 -1
  14. jarviscore/docs/USER_GUIDE.md +286 -5
  15. jarviscore/p2p/coordinator.py +36 -7
  16. jarviscore/p2p/messages.py +13 -0
  17. jarviscore/p2p/peer_client.py +355 -23
  18. jarviscore/p2p/peer_tool.py +17 -11
  19. jarviscore/profiles/customagent.py +9 -2
  20. jarviscore/testing/__init__.py +35 -0
  21. jarviscore/testing/mocks.py +578 -0
  22. {jarviscore_framework-0.3.1.dist-info → jarviscore_framework-0.3.2.dist-info}/METADATA +2 -2
  23. {jarviscore_framework-0.3.1.dist-info → jarviscore_framework-0.3.2.dist-info}/RECORD +31 -24
  24. tests/test_17_session_context.py +489 -0
  25. tests/test_18_mesh_diagnostics.py +465 -0
  26. tests/test_19_async_requests.py +516 -0
  27. tests/test_20_load_balancing.py +546 -0
  28. tests/test_21_mock_testing.py +776 -0
  29. {jarviscore_framework-0.3.1.dist-info → jarviscore_framework-0.3.2.dist-info}/WHEEL +0 -0
  30. {jarviscore_framework-0.3.1.dist-info → jarviscore_framework-0.3.2.dist-info}/licenses/LICENSE +0 -0
  31. {jarviscore_framework-0.3.1.dist-info → jarviscore_framework-0.3.2.dist-info}/top_level.txt +0 -0
@@ -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
- payload = message.get('payload', {})
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.debug(f"Delivered peer request to {target}")
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
- payload = message.get('payload', {})
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)
@@ -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
@@ -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
- analysts = self.peers.discover(capability="analysis")
151
- for peer in analysts:
152
- print(f"Found: {peer.role} - {peer.capabilities}")
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(self, target: str, message: Dict[str, Any]) -> bool:
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(self, message: IncomingMessage, response: Dict[str, Any]) -> bool:
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(self, message: Dict[str, Any]) -> int:
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(