by-framework 0.2.2.dev2__py3-none-any.whl → 0.2.2.dev4__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 (49) hide show
  1. by_framework/client/client.py +15 -7
  2. by_framework/core/__init__.py +69 -0
  3. by_framework/core/availability.py +495 -0
  4. by_framework/core/delivery_gate.py +60 -0
  5. by_framework/core/discovery.py +359 -0
  6. by_framework/core/extensions/__init__.py +29 -0
  7. by_framework/core/extensions/agent_config.py +64 -0
  8. by_framework/core/extensions/plugin.py +312 -0
  9. by_framework/core/extensions/registry.py +704 -0
  10. by_framework/core/extensions/trace_provider.py +20 -0
  11. by_framework/core/protocol/__init__.py +99 -0
  12. by_framework/core/protocol/action_type.py +33 -0
  13. by_framework/core/protocol/agent_state.py +78 -0
  14. by_framework/core/protocol/byai_codec.py +96 -0
  15. by_framework/core/protocol/byai_command.py +53 -0
  16. by_framework/core/protocol/byai_types.py +7 -0
  17. by_framework/core/protocol/commands.py +285 -0
  18. by_framework/core/protocol/content_codec.py +17 -0
  19. by_framework/core/protocol/content_type.py +38 -0
  20. by_framework/core/protocol/data_message.py +45 -0
  21. by_framework/core/protocol/data_shapes.py +83 -0
  22. by_framework/core/protocol/event_type.py +34 -0
  23. by_framework/core/protocol/events.py +69 -0
  24. by_framework/core/protocol/message.py +99 -0
  25. by_framework/core/protocol/message_header.py +78 -0
  26. by_framework/core/protocol/responses.py +94 -0
  27. by_framework/core/protocol/results.py +149 -0
  28. by_framework/core/registry.py +1102 -0
  29. by_framework/core/runtime/__init__.py +27 -0
  30. by_framework/core/runtime/agent_config_manager.py +283 -0
  31. by_framework/core/runtime/agent_runtime_state.py +75 -0
  32. by_framework/core/runtime/file_manager.py +434 -0
  33. by_framework/core/runtime/file_paths.py +76 -0
  34. by_framework/core/runtime/file_permissions.py +71 -0
  35. by_framework/core/runtime/filestore/__init__.py +15 -0
  36. by_framework/core/runtime/filestore/base.py +140 -0
  37. by_framework/core/runtime/filestore/local.py +313 -0
  38. by_framework/core/runtime/history/__init__.py +10 -0
  39. by_framework/core/runtime/history/base.py +57 -0
  40. by_framework/core/runtime/history/history_manager.py +55 -0
  41. by_framework/core/runtime/history/in_memory.py +58 -0
  42. by_framework/core/runtime/session_manager.py +118 -0
  43. by_framework/core/wakeup_controller.py +149 -0
  44. by_framework/core/workspace.py +126 -0
  45. {by_framework-0.2.2.dev2.dist-info → by_framework-0.2.2.dev4.dist-info}/METADATA +1 -1
  46. by_framework-0.2.2.dev4.dist-info/RECORD +92 -0
  47. by_framework-0.2.2.dev2.dist-info/RECORD +0 -49
  48. {by_framework-0.2.2.dev2.dist-info → by_framework-0.2.2.dev4.dist-info}/WHEEL +0 -0
  49. {by_framework-0.2.2.dev2.dist-info → by_framework-0.2.2.dev4.dist-info}/licenses/LICENSE +0 -0
@@ -96,6 +96,16 @@ class GatewayClient:
96
96
  )
97
97
  self.interceptors = interceptors or []
98
98
  self.span_recorder = span_recorder or SpanRecorder(self.redis)
99
+ self._langfuse_dispatch_fn = self._resolve_langfuse_dispatch_fn()
100
+
101
+ @staticmethod
102
+ def _resolve_langfuse_dispatch_fn() -> Any:
103
+ try:
104
+ from by_framework_trace_langfuse import start_client_dispatch_observation
105
+
106
+ return start_client_dispatch_observation
107
+ except ImportError:
108
+ return None
99
109
 
100
110
  def add_interceptor(self, interceptor: GatewayInterceptor):
101
111
  self.interceptors.append(interceptor)
@@ -619,7 +629,7 @@ class GatewayClient:
619
629
  )
620
630
 
621
631
  langfuse_client_dispatch = None
622
- if not params["parent_message_id"]:
632
+ if not langfuse_parent_observation_id:
623
633
  langfuse_client_dispatch = self._start_langfuse_client_dispatch_observation(
624
634
  trace_id=trace_id,
625
635
  message_id=message_id,
@@ -631,8 +641,7 @@ class GatewayClient:
631
641
  metadata=metadata,
632
642
  )
633
643
  observation_id = getattr(langfuse_client_dispatch, "id", "")
634
- if observation_id:
635
- langfuse_parent_observation_id = observation_id
644
+ langfuse_parent_observation_id = observation_id or trace_parent_span_id
636
645
 
637
646
  header = MessageHeader(
638
647
  message_id=message_id,
@@ -860,10 +869,10 @@ class GatewayClient:
860
869
  content: Any,
861
870
  metadata: Dict[str, Any],
862
871
  ) -> Any:
872
+ if self._langfuse_dispatch_fn is None:
873
+ return None
863
874
  try:
864
- from by_framework_trace_langfuse import start_client_dispatch_observation
865
-
866
- return start_client_dispatch_observation(
875
+ return self._langfuse_dispatch_fn(
867
876
  trace_id=trace_id,
868
877
  message_id=message_id,
869
878
  target_agent_type=target_agent_type,
@@ -877,7 +886,6 @@ class GatewayClient:
877
886
  logger.warning(
878
887
  "Langfuse client.dispatch observation skipped: %s",
879
888
  err,
880
- exc_info=True,
881
889
  )
882
890
  return None
883
891
 
@@ -0,0 +1,69 @@
1
+ """
2
+ Core module of the Gateway SDK.
3
+
4
+ Provides core components including protocol definitions, worker registry,
5
+ and workspace management.
6
+ """
7
+
8
+ from .availability import (AvailabilityResult, AvailabilityRouter,
9
+ AvailabilityStatus, DeliveryIntent, PendingDelivery,
10
+ RoutePolicy, WakeupDecision, WakeupDecisionStatus,
11
+ WakeupRequest)
12
+ from .delivery_gate import DeliveryGate
13
+ from .protocol import (ActionType, AgentState, ArtifactEvent, AskAgentCommand,
14
+ AskUserEvent, BaiYingMessage, BaiYingMessageRole,
15
+ BaseCommand, CancelTaskCommand, CancelTaskResponse,
16
+ DataMessage, EventType, GatewayCommand, MessageContent,
17
+ MessageFile, MessageHeader, Resource, ResumeCommand,
18
+ SendMessageResponse, SseMessageType,
19
+ SseReasonMessageType, StateChangeEvent,
20
+ StreamChunkEvent, command_from_dict,
21
+ get_registered_command, register_command,
22
+ unregister_command)
23
+ from .registry import WorkerRegistry
24
+ from .wakeup_controller import WakeupController, WakeupProvider
25
+ from .workspace import WorkspaceManager
26
+
27
+ __all__ = [
28
+ "SendMessageResponse",
29
+ "CancelTaskResponse",
30
+ "BaiYingMessage",
31
+ "BaiYingMessageRole",
32
+ "MessageContent",
33
+ "MessageFile",
34
+ "Resource",
35
+ "DataMessage",
36
+ "ActionType",
37
+ "AgentState",
38
+ "EventType",
39
+ "StateChangeEvent",
40
+ "StreamChunkEvent",
41
+ "ArtifactEvent",
42
+ "AskUserEvent",
43
+ "SseMessageType",
44
+ "SseReasonMessageType",
45
+ "MessageHeader",
46
+ "BaseCommand",
47
+ "AskAgentCommand",
48
+ "ResumeCommand",
49
+ "CancelTaskCommand",
50
+ "GatewayCommand",
51
+ "command_from_dict",
52
+ "register_command",
53
+ "unregister_command",
54
+ "get_registered_command",
55
+ "WorkerRegistry",
56
+ "WorkspaceManager",
57
+ "AvailabilityRouter",
58
+ "AvailabilityStatus",
59
+ "AvailabilityResult",
60
+ "DeliveryIntent",
61
+ "RoutePolicy",
62
+ "PendingDelivery",
63
+ "WakeupRequest",
64
+ "WakeupDecision",
65
+ "WakeupDecisionStatus",
66
+ "WakeupController",
67
+ "WakeupProvider",
68
+ "DeliveryGate",
69
+ ]
@@ -0,0 +1,495 @@
1
+ """Agent availability control-plane routing.
2
+
3
+ This module centralizes online-worker checks and wakeup handshakes so client
4
+ calls and inter-agent calls share the same offline routing behavior.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ from dataclasses import asdict, dataclass, field
12
+ from typing import Any, Optional
13
+
14
+ from by_framework.common.constants import RedisKeys
15
+ from by_framework.common.redis_client import Redis
16
+ from by_framework.core.registry import WorkerRegistry, check_agent_type_online
17
+
18
+
19
+ class RoutePolicy:
20
+ """Policy values for routing and availability checks."""
21
+
22
+ FAIL_FAST = "FAIL_FAST"
23
+ SEND_ANYWAY = "SEND_ANYWAY"
24
+ WAKE_AND_WAIT = "WAKE_AND_WAIT"
25
+ WAKE_AND_QUEUE = "WAKE_AND_QUEUE"
26
+ QUEUE_ONLY = "QUEUE_ONLY"
27
+
28
+
29
+ class WakeupDecisionStatus:
30
+ """Wakeup controller decision status values."""
31
+
32
+ READY = "READY"
33
+ STARTING = "STARTING"
34
+ QUEUED = "QUEUED"
35
+ REJECTED = "REJECTED"
36
+ FAILED = "FAILED"
37
+ FALLBACK = "FALLBACK"
38
+
39
+
40
+ class AvailabilityStatus:
41
+ """Availability router result values."""
42
+
43
+ DELIVER_NOW = "DELIVER_NOW"
44
+ WAIT_AND_DELIVER = "WAIT_AND_DELIVER"
45
+ QUEUE_PENDING = "QUEUE_PENDING"
46
+ REJECT = "REJECT"
47
+ FALLBACK_TO_OTHER_AGENT_TYPE = "FALLBACK_TO_OTHER_AGENT_TYPE"
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class DeliveryIntent:
52
+ """Framework-internal representation of a control-message delivery."""
53
+
54
+ execution_id: str
55
+ message_id: str
56
+ session_id: str
57
+ trace_id: str
58
+ source: str
59
+ target_agent_type: str
60
+ user_code: str = ""
61
+ region: str = ""
62
+ priority: int = 0
63
+ policy: str = RoutePolicy.FAIL_FAST
64
+ timeout_ms: int = 30000
65
+ command_payload: dict[str, Any] = field(default_factory=dict)
66
+ metadata: dict[str, Any] = field(default_factory=dict)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class WakeupRequest:
71
+ """Redis management event emitted when a target agent type is unavailable."""
72
+
73
+ execution_id: str
74
+ target_agent_type: str
75
+ session_id: str
76
+ trace_id: str
77
+ message_id: str
78
+ source: str
79
+ policy: str
80
+ timeout_ms: int
81
+ user_code: str = ""
82
+ region: str = ""
83
+ priority: int = 0
84
+ metadata: dict[str, Any] = field(default_factory=dict)
85
+ command_payload: dict[str, Any] = field(default_factory=dict)
86
+
87
+ def to_redis_payload(self) -> dict[str, str]:
88
+ return {"data": json.dumps(asdict(self))}
89
+
90
+ @classmethod
91
+ def from_dict(cls, data: dict[str, Any]) -> "WakeupRequest":
92
+ return cls(
93
+ execution_id=str(data.get("execution_id", "")),
94
+ target_agent_type=str(data.get("target_agent_type", "")),
95
+ session_id=str(data.get("session_id", "")),
96
+ trace_id=str(data.get("trace_id", "")),
97
+ message_id=str(data.get("message_id", "")),
98
+ source=str(data.get("source", "")),
99
+ policy=str(data.get("policy", RoutePolicy.FAIL_FAST)),
100
+ timeout_ms=int(data.get("timeout_ms", 30000)),
101
+ user_code=str(data.get("user_code") or data.get("tenant_id", "")),
102
+ region=str(data.get("region", "")),
103
+ priority=int(data.get("priority", 0)),
104
+ metadata=dict(data.get("metadata", {})),
105
+ command_payload=dict(data.get("command_payload", {})),
106
+ )
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class PendingDelivery:
111
+ """Control command held until a wakeup decision allows dispatch."""
112
+
113
+ execution_id: str
114
+ message_id: str
115
+ session_id: str
116
+ trace_id: str
117
+ target_agent_type: str
118
+ delivery_stream: str
119
+ command_payload: dict[str, Any]
120
+ user_code: str = ""
121
+ region: str = ""
122
+ priority: int = 0
123
+ metadata: dict[str, Any] = field(default_factory=dict)
124
+
125
+ def to_redis_payload(self) -> dict[str, str]:
126
+ return {"data": json.dumps(asdict(self))}
127
+
128
+ @classmethod
129
+ def from_dict(cls, data: dict[str, Any]) -> "PendingDelivery":
130
+ return cls(
131
+ execution_id=str(data.get("execution_id", "")),
132
+ message_id=str(data.get("message_id", "")),
133
+ session_id=str(data.get("session_id", "")),
134
+ trace_id=str(data.get("trace_id", "")),
135
+ target_agent_type=str(data.get("target_agent_type", "")),
136
+ delivery_stream=str(data.get("delivery_stream", "")),
137
+ command_payload=dict(data.get("command_payload", {})),
138
+ user_code=str(data.get("user_code") or data.get("tenant_id", "")),
139
+ region=str(data.get("region", "")),
140
+ priority=int(data.get("priority", 0)),
141
+ metadata=dict(data.get("metadata", {})),
142
+ )
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class WakeupDecision:
147
+ """Decision returned by the manager-owned wakeup controller."""
148
+
149
+ execution_id: str = ""
150
+ target_agent_type: str = ""
151
+ status: str = WakeupDecisionStatus.FAILED
152
+ selected_agent_type: str = ""
153
+ worker_ids: list[str] = field(default_factory=list)
154
+ region: str = ""
155
+ retry_after_ms: Optional[int] = None
156
+ reason: str = ""
157
+
158
+ @classmethod
159
+ def from_dict(cls, data: dict[str, Any]) -> "WakeupDecision":
160
+ return cls(
161
+ execution_id=str(data.get("execution_id", "")),
162
+ target_agent_type=str(data.get("target_agent_type", "")),
163
+ status=str(data.get("status", WakeupDecisionStatus.FAILED)),
164
+ selected_agent_type=str(data.get("selected_agent_type", "")),
165
+ worker_ids=list(data.get("worker_ids", [])),
166
+ region=str(data.get("region", "")),
167
+ retry_after_ms=data.get("retry_after_ms"),
168
+ reason=str(data.get("reason", "")),
169
+ )
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class AvailabilityResult:
174
+ """Result returned by AvailabilityRouter before control-message delivery."""
175
+
176
+ status: str
177
+ stream_name: str = ""
178
+ target_worker_id: str = ""
179
+ error: str = ""
180
+ error_code: str = ""
181
+ selected_agent_type: str = ""
182
+ execution_id: str = ""
183
+
184
+
185
+ class AvailabilityRouter:
186
+ """Shared availability and wakeup router for client and agent calls."""
187
+
188
+ def __init__(
189
+ self,
190
+ redis: Redis,
191
+ registry: Optional[WorkerRegistry] = None,
192
+ ):
193
+ self.redis = redis
194
+ self.registry = registry
195
+
196
+ async def prepare_delivery(
197
+ self,
198
+ intent: DeliveryIntent,
199
+ ) -> AvailabilityResult:
200
+ """Resolve whether a command can be delivered now or needs wakeup."""
201
+ policy_rejection = await self._check_control_plane_policy(intent)
202
+ if policy_rejection is not None:
203
+ return policy_rejection
204
+
205
+ if intent.policy == RoutePolicy.SEND_ANYWAY:
206
+ return AvailabilityResult(
207
+ status=AvailabilityStatus.DELIVER_NOW,
208
+ stream_name=RedisKeys.ctrl_stream(intent.target_agent_type),
209
+ execution_id=intent.execution_id,
210
+ )
211
+
212
+ has_online_worker = await self._has_online_agent_type(intent.target_agent_type)
213
+ if has_online_worker:
214
+ return AvailabilityResult(
215
+ status=AvailabilityStatus.DELIVER_NOW,
216
+ stream_name=RedisKeys.ctrl_stream(intent.target_agent_type),
217
+ execution_id=intent.execution_id,
218
+ )
219
+
220
+ fallback = await self._resolve_configured_fallback(intent)
221
+ if fallback is not None:
222
+ return fallback
223
+
224
+ if intent.policy == RoutePolicy.FAIL_FAST:
225
+ return self._unavailable(intent)
226
+
227
+ if intent.policy == RoutePolicy.WAKE_AND_WAIT:
228
+ return await self._wake_and_wait(intent)
229
+
230
+ if intent.policy == RoutePolicy.WAKE_AND_QUEUE:
231
+ return await self._wake_and_queue(intent)
232
+
233
+ if intent.policy == RoutePolicy.QUEUE_ONLY:
234
+ return await self._queue_pending(intent)
235
+
236
+ return AvailabilityResult(
237
+ status=AvailabilityStatus.REJECT,
238
+ error=f"Unsupported offline route policy '{intent.policy}'",
239
+ error_code="AGENT_TYPE_UNAVAILABLE",
240
+ execution_id=intent.execution_id,
241
+ )
242
+
243
+ async def _check_control_plane_policy(
244
+ self, intent: DeliveryIntent
245
+ ) -> Optional[AvailabilityResult]:
246
+ circuit = await self._read_json_key(
247
+ RedisKeys.control_plane_agent_circuit(intent.target_agent_type)
248
+ )
249
+ if circuit and str(circuit.get("state", "")).upper() == "OPEN":
250
+ return AvailabilityResult(
251
+ status=AvailabilityStatus.REJECT,
252
+ error=str(circuit.get("reason") or "agent circuit is open"),
253
+ error_code="AGENT_CIRCUIT_OPEN",
254
+ execution_id=intent.execution_id,
255
+ )
256
+
257
+ if intent.user_code:
258
+ quota = await self._read_json_key(
259
+ RedisKeys.control_plane_user_quota(intent.user_code)
260
+ )
261
+ if quota and quota.get("available") is False:
262
+ return AvailabilityResult(
263
+ status=AvailabilityStatus.REJECT,
264
+ error=str(quota.get("reason") or "tenant quota exceeded"),
265
+ error_code="TENANT_QUOTA_EXCEEDED",
266
+ execution_id=intent.execution_id,
267
+ )
268
+
269
+ return None
270
+
271
+ async def _resolve_configured_fallback(
272
+ self, intent: DeliveryIntent
273
+ ) -> Optional[AvailabilityResult]:
274
+ fallback = await self._read_json_key(
275
+ RedisKeys.control_plane_agent_fallback(intent.target_agent_type)
276
+ )
277
+ if not fallback:
278
+ return None
279
+
280
+ selected = str(
281
+ fallback.get("selected_agent_type")
282
+ or fallback.get("agent_type")
283
+ or fallback.get("target_agent_type")
284
+ or ""
285
+ )
286
+ if not selected:
287
+ return None
288
+
289
+ if await self._has_online_agent_type(selected):
290
+ return AvailabilityResult(
291
+ status=AvailabilityStatus.FALLBACK_TO_OTHER_AGENT_TYPE,
292
+ stream_name=RedisKeys.ctrl_stream(selected),
293
+ selected_agent_type=selected,
294
+ execution_id=intent.execution_id,
295
+ )
296
+
297
+ return None
298
+
299
+ async def _wake_and_queue(self, intent: DeliveryIntent) -> AvailabilityResult:
300
+ request = WakeupRequest(
301
+ execution_id=intent.execution_id,
302
+ target_agent_type=intent.target_agent_type,
303
+ session_id=intent.session_id,
304
+ trace_id=intent.trace_id,
305
+ message_id=intent.message_id,
306
+ source=intent.source,
307
+ policy=intent.policy,
308
+ timeout_ms=intent.timeout_ms,
309
+ user_code=intent.user_code,
310
+ region=intent.region,
311
+ priority=intent.priority,
312
+ metadata=intent.metadata,
313
+ command_payload=intent.command_payload,
314
+ )
315
+ await self.redis.xadd(
316
+ RedisKeys.control_plane_wakeup_stream(), request.to_redis_payload()
317
+ )
318
+ return await self._queue_pending(intent)
319
+
320
+ async def _queue_pending(self, intent: DeliveryIntent) -> AvailabilityResult:
321
+ pending = PendingDelivery(
322
+ execution_id=intent.execution_id,
323
+ message_id=intent.message_id,
324
+ session_id=intent.session_id,
325
+ trace_id=intent.trace_id,
326
+ target_agent_type=intent.target_agent_type,
327
+ delivery_stream=RedisKeys.ctrl_stream(intent.target_agent_type),
328
+ command_payload=intent.command_payload,
329
+ user_code=intent.user_code,
330
+ region=intent.region,
331
+ priority=intent.priority,
332
+ metadata=intent.metadata,
333
+ )
334
+ await self.redis.xadd(
335
+ RedisKeys.control_plane_delivery_pending_stream(),
336
+ pending.to_redis_payload(),
337
+ )
338
+ return AvailabilityResult(
339
+ status=AvailabilityStatus.QUEUE_PENDING,
340
+ stream_name=RedisKeys.control_plane_delivery_pending_stream(),
341
+ execution_id=intent.execution_id,
342
+ )
343
+
344
+ async def _wake_and_wait(self, intent: DeliveryIntent) -> AvailabilityResult:
345
+ request = WakeupRequest(
346
+ execution_id=intent.execution_id,
347
+ target_agent_type=intent.target_agent_type,
348
+ session_id=intent.session_id,
349
+ trace_id=intent.trace_id,
350
+ message_id=intent.message_id,
351
+ source=intent.source,
352
+ policy=intent.policy,
353
+ timeout_ms=intent.timeout_ms,
354
+ user_code=intent.user_code,
355
+ region=intent.region,
356
+ priority=intent.priority,
357
+ metadata=intent.metadata,
358
+ command_payload=intent.command_payload,
359
+ )
360
+ await self.redis.xadd(
361
+ RedisKeys.control_plane_wakeup_stream(), request.to_redis_payload()
362
+ )
363
+
364
+ decision = await self._wait_for_wakeup_decision(intent)
365
+ if decision is None:
366
+ return AvailabilityResult(
367
+ status=AvailabilityStatus.REJECT,
368
+ error=(
369
+ f"Timed out waiting for worker wakeup for agent_type "
370
+ f"'{intent.target_agent_type}'"
371
+ ),
372
+ error_code="AGENT_TYPE_UNAVAILABLE",
373
+ execution_id=intent.execution_id,
374
+ )
375
+
376
+ if decision.status == WakeupDecisionStatus.READY:
377
+ has_online_worker = await self._has_online_agent_type(
378
+ intent.target_agent_type
379
+ )
380
+ if has_online_worker:
381
+ return AvailabilityResult(
382
+ status=AvailabilityStatus.WAIT_AND_DELIVER,
383
+ stream_name=RedisKeys.ctrl_stream(intent.target_agent_type),
384
+ execution_id=intent.execution_id,
385
+ )
386
+ return self._unavailable(intent)
387
+
388
+ if decision.status == WakeupDecisionStatus.FALLBACK:
389
+ selected = decision.selected_agent_type
390
+ return AvailabilityResult(
391
+ status=AvailabilityStatus.FALLBACK_TO_OTHER_AGENT_TYPE,
392
+ stream_name=RedisKeys.ctrl_stream(selected) if selected else "",
393
+ selected_agent_type=selected,
394
+ execution_id=intent.execution_id,
395
+ )
396
+
397
+ return AvailabilityResult(
398
+ status=AvailabilityStatus.REJECT,
399
+ error=decision.reason
400
+ or f"Wakeup rejected for agent_type '{intent.target_agent_type}'",
401
+ error_code="AGENT_TYPE_UNAVAILABLE",
402
+ execution_id=intent.execution_id,
403
+ )
404
+
405
+ async def _wait_for_wakeup_decision(
406
+ self, intent: DeliveryIntent
407
+ ) -> Optional[WakeupDecision]:
408
+ deadline = time.monotonic() + max(intent.timeout_ms, 0) / 1000
409
+ result_stream = RedisKeys.control_plane_wakeup_result_stream(
410
+ intent.execution_id
411
+ )
412
+ last_id = "0-0"
413
+
414
+ while True:
415
+ remaining_ms = int((deadline - time.monotonic()) * 1000)
416
+ if remaining_ms <= 0:
417
+ return None
418
+
419
+ messages = await self.redis.xread(
420
+ streams={result_stream: last_id},
421
+ count=1,
422
+ block=remaining_ms,
423
+ )
424
+ if not messages:
425
+ return None
426
+
427
+ for _, entries in messages:
428
+ for entry_id, fields in entries:
429
+ last_id = (
430
+ entry_id.decode("utf-8")
431
+ if isinstance(entry_id, bytes)
432
+ else str(entry_id)
433
+ )
434
+ decision = self._decode_decision(fields)
435
+ if decision is None:
436
+ continue
437
+ if (
438
+ decision.execution_id
439
+ and decision.execution_id != intent.execution_id
440
+ ):
441
+ continue
442
+ if decision.status in (
443
+ WakeupDecisionStatus.STARTING,
444
+ WakeupDecisionStatus.QUEUED,
445
+ ):
446
+ break
447
+ return decision
448
+
449
+ async def _has_online_agent_type(self, agent_type: str) -> bool:
450
+ if self.registry is not None:
451
+ has_online_agent_type, _ = await self.registry.has_online_agent_type(
452
+ agent_type
453
+ )
454
+ return bool(has_online_agent_type)
455
+
456
+ has_online_agent_type, _ = await check_agent_type_online(
457
+ self.redis, agent_type, check_active=True
458
+ )
459
+ return bool(has_online_agent_type)
460
+
461
+ @staticmethod
462
+ def _decode_decision(fields: dict[Any, Any]) -> Optional[WakeupDecision]:
463
+ raw = fields.get(b"data") if isinstance(fields, dict) else None
464
+ if raw is None and isinstance(fields, dict):
465
+ raw = fields.get("data")
466
+ if raw is None:
467
+ return None
468
+ if isinstance(raw, bytes):
469
+ raw = raw.decode("utf-8")
470
+ return WakeupDecision.from_dict(json.loads(raw))
471
+
472
+ async def _read_json_key(self, key: str) -> Optional[dict[str, Any]]:
473
+ if not hasattr(self.redis, "get"):
474
+ return None
475
+ raw = await self.redis.get(key)
476
+ if raw is None:
477
+ return None
478
+ if isinstance(raw, bytes):
479
+ raw = raw.decode("utf-8")
480
+ if not isinstance(raw, str):
481
+ return None
482
+ try:
483
+ decoded = json.loads(raw)
484
+ except json.JSONDecodeError:
485
+ return None
486
+ return decoded if isinstance(decoded, dict) else None
487
+
488
+ @staticmethod
489
+ def _unavailable(intent: DeliveryIntent) -> AvailabilityResult:
490
+ return AvailabilityResult(
491
+ status=AvailabilityStatus.REJECT,
492
+ error=f"No online worker found for agent_type '{intent.target_agent_type}'",
493
+ error_code="AGENT_TYPE_UNAVAILABLE",
494
+ execution_id=intent.execution_id,
495
+ )
@@ -0,0 +1,60 @@
1
+ """Pending-delivery gate for the availability control plane."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from by_framework.common.constants import RedisKeys
9
+ from by_framework.common.redis_client import Redis
10
+ from by_framework.core.availability import PendingDelivery
11
+
12
+
13
+ class DeliveryGate:
14
+ """Reference component that releases pending deliveries after wakeup."""
15
+
16
+ def __init__(self, redis: Redis):
17
+ self.redis = redis
18
+
19
+ async def dispatch_ready(
20
+ self,
21
+ execution_id: str,
22
+ *,
23
+ last_id: str = "0-0",
24
+ count: int = 100,
25
+ ) -> int:
26
+ """Dispatch pending deliveries matching a ready wakeup request."""
27
+ messages = await self.redis.xread(
28
+ streams={RedisKeys.control_plane_delivery_pending_stream(): last_id},
29
+ count=count,
30
+ block=0,
31
+ )
32
+ dispatched = 0
33
+ pending_deliveries: list[PendingDelivery] = []
34
+ for _, entries in messages or []:
35
+ for _, fields in entries:
36
+ pending = self._decode_pending(fields)
37
+ if pending is None or pending.execution_id != execution_id:
38
+ continue
39
+ pending_deliveries.append(pending)
40
+
41
+ for pending in sorted(
42
+ pending_deliveries, key=lambda delivery: delivery.priority, reverse=True
43
+ ):
44
+ await self.redis.xadd(
45
+ pending.delivery_stream,
46
+ {"data": json.dumps(pending.command_payload)},
47
+ )
48
+ dispatched += 1
49
+ return dispatched
50
+
51
+ @staticmethod
52
+ def _decode_pending(fields: dict[Any, Any]) -> PendingDelivery | None:
53
+ raw = fields.get(b"data") if isinstance(fields, dict) else None
54
+ if raw is None and isinstance(fields, dict):
55
+ raw = fields.get("data")
56
+ if raw is None:
57
+ return None
58
+ if isinstance(raw, bytes):
59
+ raw = raw.decode("utf-8")
60
+ return PendingDelivery.from_dict(json.loads(raw))