by-framework 0.2.2.dev3__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.
- by_framework/client/client.py +15 -7
- by_framework/core/__init__.py +69 -0
- by_framework/core/availability.py +495 -0
- by_framework/core/delivery_gate.py +60 -0
- by_framework/core/discovery.py +359 -0
- by_framework/core/extensions/__init__.py +29 -0
- by_framework/core/extensions/agent_config.py +64 -0
- by_framework/core/extensions/plugin.py +312 -0
- by_framework/core/extensions/registry.py +704 -0
- by_framework/core/extensions/trace_provider.py +20 -0
- by_framework/core/protocol/__init__.py +99 -0
- by_framework/core/protocol/action_type.py +33 -0
- by_framework/core/protocol/agent_state.py +78 -0
- by_framework/core/protocol/byai_codec.py +96 -0
- by_framework/core/protocol/byai_command.py +53 -0
- by_framework/core/protocol/byai_types.py +7 -0
- by_framework/core/protocol/commands.py +285 -0
- by_framework/core/protocol/content_codec.py +17 -0
- by_framework/core/protocol/content_type.py +38 -0
- by_framework/core/protocol/data_message.py +45 -0
- by_framework/core/protocol/data_shapes.py +83 -0
- by_framework/core/protocol/event_type.py +34 -0
- by_framework/core/protocol/events.py +69 -0
- by_framework/core/protocol/message.py +99 -0
- by_framework/core/protocol/message_header.py +78 -0
- by_framework/core/protocol/responses.py +94 -0
- by_framework/core/protocol/results.py +149 -0
- by_framework/core/registry.py +1102 -0
- by_framework/core/runtime/__init__.py +27 -0
- by_framework/core/runtime/agent_config_manager.py +283 -0
- by_framework/core/runtime/agent_runtime_state.py +75 -0
- by_framework/core/runtime/file_manager.py +434 -0
- by_framework/core/runtime/file_paths.py +76 -0
- by_framework/core/runtime/file_permissions.py +71 -0
- by_framework/core/runtime/filestore/__init__.py +15 -0
- by_framework/core/runtime/filestore/base.py +140 -0
- by_framework/core/runtime/filestore/local.py +313 -0
- by_framework/core/runtime/history/__init__.py +10 -0
- by_framework/core/runtime/history/base.py +57 -0
- by_framework/core/runtime/history/history_manager.py +55 -0
- by_framework/core/runtime/history/in_memory.py +58 -0
- by_framework/core/runtime/session_manager.py +118 -0
- by_framework/core/wakeup_controller.py +149 -0
- by_framework/core/workspace.py +126 -0
- {by_framework-0.2.2.dev3.dist-info → by_framework-0.2.2.dev4.dist-info}/METADATA +1 -1
- by_framework-0.2.2.dev4.dist-info/RECORD +92 -0
- by_framework-0.2.2.dev3.dist-info/RECORD +0 -49
- {by_framework-0.2.2.dev3.dist-info → by_framework-0.2.2.dev4.dist-info}/WHEEL +0 -0
- {by_framework-0.2.2.dev3.dist-info → by_framework-0.2.2.dev4.dist-info}/licenses/LICENSE +0 -0
by_framework/client/client.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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))
|