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
@@ -0,0 +1,359 @@
1
+ """Service discovery module with Redis-based registry and local cache."""
2
+
3
+ import asyncio
4
+ import json
5
+ import random
6
+ import socket
7
+ import time
8
+ import uuid
9
+ from dataclasses import asdict, dataclass, field
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from by_framework.common.constants import RedisKeys
13
+ from by_framework.common.redis_client import Redis, get_redis
14
+
15
+
16
+ def get_local_ip(target_host: str = "8.8.8.8", target_port: int = 80) -> str:
17
+ """Get the local machine's outbound IP address.
18
+
19
+ Detects the local network interface IP by attempting to connect to the target
20
+ host (without sending data). If target_host is localhost or 127.0.0.1, attempts
21
+ to connect to a public address for detection. If that fails or the environment
22
+ is restricted, attempts to return a non-loopback address.
23
+ """
24
+ # If the target is a loopback address, detection may return 127.0.0.1, which is
25
+ # meaningless for cross-machine communication. In this case, try connecting to a
26
+ # public or external address to detect the actual local outbound IP
27
+ is_loopback = target_host in ("127.0.0.1", "localhost", "::1")
28
+ actual_target = ("8.8.8.8", 80) if is_loopback else (target_host, target_port)
29
+
30
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
31
+ try:
32
+ s.connect(actual_target)
33
+ ip = s.getsockname()[0]
34
+ except Exception: # pylint: disable=broad-exception-caught
35
+ # Fallback: get IP for the hostname
36
+ try:
37
+ ip = socket.gethostbyname(socket.gethostname())
38
+ except Exception: # pylint: disable=broad-exception-caught
39
+ ip = "127.0.0.1"
40
+ finally:
41
+ s.close()
42
+
43
+ # If the acquired address is a loopback but other network interfaces exist,
44
+ # more complex scanning could be expanded here
45
+ return ip
46
+
47
+
48
+ @dataclass
49
+ class ServiceInstance:
50
+ """Service instance data structure."""
51
+
52
+ id: str
53
+ host: str
54
+ port: int
55
+ protocol: str = "http"
56
+ path_prefix: Optional[str] = None
57
+ weight: int = 1
58
+ metadata: Dict[str, Any] = field(default_factory=dict)
59
+ last_heartbeat: int = field(default=0, repr=False)
60
+
61
+ def to_json(self) -> str:
62
+ payload = asdict(self)
63
+ payload.pop("last_heartbeat", None)
64
+ return json.dumps(payload, ensure_ascii=False)
65
+
66
+ @classmethod
67
+ def from_json(cls, data: str) -> "ServiceInstance":
68
+ return cls(**json.loads(data))
69
+
70
+
71
+ class ServiceRegistry:
72
+ """Redis-based service registry SDK.
73
+
74
+ Used by server-side for service registration, automatic heartbeat
75
+ maintenance, and deregistration.
76
+ """
77
+
78
+ def __init__(self, redis_client: Optional[Redis] = None):
79
+ self.redis = redis_client or get_redis()
80
+ self._heartbeat_task: Optional[asyncio.Task] = None
81
+ self._current_instance: Optional[ServiceInstance] = None
82
+ self._current_service_name: Optional[str] = None
83
+
84
+ async def register(
85
+ self,
86
+ service_name: str,
87
+ host: Optional[str] = None,
88
+ port: int = 0,
89
+ weight: int = 1,
90
+ metadata: Optional[Dict[str, Any]] = None,
91
+ heartbeat_interval: int = RedisKeys.SD_DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
92
+ protocol: str = "http",
93
+ path_prefix: Optional[str] = None,
94
+ ) -> None:
95
+ """Register the current service instance and start background heartbeat.
96
+
97
+ Args:
98
+ service_name: Service name.
99
+ host: Instance listening address. If None, auto-detected from Redis
100
+ connection address.
101
+ port: Instance listening port. Defaults to 0 (meaning no listening port).
102
+ weight: Load balancing weight.
103
+ metadata: Metadata.
104
+ heartbeat_interval: Heartbeat interval.
105
+ protocol: Service protocol used by discovery-aware clients.
106
+ path_prefix: Optional URL prefix advertised by this instance.
107
+ """
108
+ if host is None:
109
+ # Attempt to get target address from Redis connection config
110
+ redis_host = "8.8.8.8"
111
+ redis_port = 80
112
+ try:
113
+ pool = getattr(self.redis, "connection_pool", None)
114
+ if pool:
115
+ redis_host = pool.connection_kwargs.get("host", "8.8.8.8")
116
+ redis_port = pool.connection_kwargs.get("port", 6379)
117
+ except Exception: # pylint: disable=broad-exception-caught
118
+ pass
119
+
120
+ host = get_local_ip(target_host=redis_host, target_port=redis_port)
121
+
122
+ instance_id = f"{service_name}:{uuid.uuid4().hex[:8]}"
123
+ self._current_instance = ServiceInstance(
124
+ id=instance_id,
125
+ protocol=protocol or "http",
126
+ host=host,
127
+ port=port,
128
+ path_prefix=path_prefix,
129
+ weight=weight,
130
+ metadata=metadata or {},
131
+ )
132
+ self._current_service_name = service_name
133
+
134
+ now_ms = int(time.time() * 1000)
135
+
136
+ # 1. Write instance details
137
+ await self.redis.hset(
138
+ RedisKeys.sd_instance_details(service_name),
139
+ instance_id,
140
+ self._current_instance.to_json(),
141
+ )
142
+ # 2. Make the instance immediately discoverable.
143
+ await self.redis.zadd(
144
+ RedisKeys.sd_active_instances(service_name), {instance_id: now_ms}
145
+ )
146
+ self._current_instance.last_heartbeat = now_ms
147
+ # 3. Add service name to global index
148
+ await self.redis.sadd(RedisKeys.SD_SERVICES, service_name)
149
+
150
+ # 4. Start recurring heartbeats only when requested.
151
+ if heartbeat_interval > RedisKeys.SD_NO_HEARTBEAT:
152
+ self._heartbeat_task = asyncio.create_task(
153
+ self._heartbeat_loop(service_name, instance_id, heartbeat_interval)
154
+ )
155
+
156
+ async def register_only(
157
+ self,
158
+ service_name: str,
159
+ host: Optional[str] = None,
160
+ port: int = 0,
161
+ weight: int = 1,
162
+ metadata: Optional[Dict[str, Any]] = None,
163
+ protocol: str = "http",
164
+ path_prefix: Optional[str] = None,
165
+ ) -> None:
166
+ """Register an instance without starting recurring heartbeats."""
167
+ await self.register(
168
+ service_name=service_name,
169
+ host=host,
170
+ port=port,
171
+ weight=weight,
172
+ metadata=metadata,
173
+ heartbeat_interval=RedisKeys.SD_NO_HEARTBEAT,
174
+ protocol=protocol,
175
+ path_prefix=path_prefix,
176
+ )
177
+
178
+ async def unregister(self):
179
+ """Deregister the current service instance and stop heartbeat."""
180
+ if self._heartbeat_task:
181
+ self._heartbeat_task.cancel()
182
+ try:
183
+ await self._heartbeat_task
184
+ except asyncio.CancelledError:
185
+ pass
186
+ self._heartbeat_task = None
187
+
188
+ if self._current_instance and self._current_service_name:
189
+ pipe = self.redis.pipeline()
190
+ pipe.hdel(
191
+ RedisKeys.sd_instance_details(self._current_service_name),
192
+ self._current_instance.id,
193
+ )
194
+ pipe.zrem(
195
+ RedisKeys.sd_active_instances(self._current_service_name),
196
+ self._current_instance.id,
197
+ )
198
+ await pipe.execute()
199
+
200
+ async def _send_heartbeat(self):
201
+ if self._current_instance and self._current_service_name:
202
+ now = int(time.time() * 1000)
203
+ self._current_instance.last_heartbeat = now
204
+ await self.redis.zadd(
205
+ RedisKeys.sd_active_instances(self._current_service_name),
206
+ {self._current_instance.id: now},
207
+ )
208
+
209
+ # pylint: disable=unused-argument
210
+ async def _heartbeat_loop(self, service_name: str, instance_id: str, interval: int):
211
+ """Background heartbeat coroutine."""
212
+ while True:
213
+ try:
214
+ await asyncio.sleep(interval)
215
+ await self._send_heartbeat()
216
+ except asyncio.CancelledError:
217
+ break
218
+ except Exception: # pylint: disable=broad-exception-caught
219
+ await asyncio.sleep(1)
220
+
221
+
222
+ class DiscoveryClient:
223
+ """Efficient service discovery client with local cache.
224
+
225
+ Used by consumers. Reduces Redis access frequency through in-memory cache
226
+ and background refresh mechanisms.
227
+ """
228
+
229
+ def __init__(
230
+ self,
231
+ redis_client: Optional[Redis] = None,
232
+ cache_interval: int = 5,
233
+ ):
234
+ self.redis = redis_client or get_redis()
235
+ self.cache_interval = cache_interval
236
+ self._cache: Dict[str, List[ServiceInstance]] = {}
237
+ self._last_refresh: Dict[str, float] = {}
238
+ self._watched_services: set[str] = set()
239
+ self._refresh_task: Optional[asyncio.Task] = None
240
+ self._rr_counters: Dict[str, int] = {}
241
+
242
+ async def get_instances(
243
+ self,
244
+ service_name: str,
245
+ force_refresh: bool = False,
246
+ health_threshold_ms: int = RedisKeys.SD_DEFAULT_HEALTH_THRESHOLD_MS,
247
+ ) -> List[ServiceInstance]:
248
+ """Get service instances. Prefers using cache."""
249
+ now = time.time()
250
+
251
+ # Determine if cache is valid
252
+ is_stale = now - self._last_refresh.get(service_name, 0) > self.cache_interval
253
+
254
+ if force_refresh or is_stale or service_name not in self._cache:
255
+ await self._refresh_service(service_name, health_threshold_ms)
256
+
257
+ instances = self._cache.get(service_name, [])
258
+ if health_threshold_ms == RedisKeys.SD_NO_HEALTH_CHECK:
259
+ return instances
260
+
261
+ min_score = int(time.time() * 1000) - health_threshold_ms
262
+ return [
263
+ instance for instance in instances if instance.last_heartbeat >= min_score
264
+ ]
265
+
266
+ async def _refresh_service(self, service_name: str, health_threshold_ms: int):
267
+ """Sync instance list from Redis and update cache."""
268
+ del health_threshold_ms
269
+
270
+ # 1. Get active IDs together with their latest heartbeat timestamp.
271
+ raw_instances = await self.redis.zrange(
272
+ RedisKeys.sd_active_instances(service_name), 0, -1, withscores=True
273
+ )
274
+
275
+ if not raw_instances:
276
+ self._cache[service_name] = []
277
+ self._last_refresh[service_name] = time.time()
278
+ return
279
+
280
+ instance_ids: list[str] = []
281
+ heartbeat_by_id: dict[str, int] = {}
282
+ for instance_id, score in raw_instances:
283
+ normalized_id = (
284
+ instance_id.decode("utf-8")
285
+ if isinstance(instance_id, bytes)
286
+ else instance_id
287
+ )
288
+ instance_ids.append(normalized_id)
289
+ heartbeat_by_id[normalized_id] = int(score)
290
+
291
+ # 2. Get details
292
+ details_raw = await self.redis.hmget(
293
+ RedisKeys.sd_instance_details(service_name), instance_ids
294
+ )
295
+
296
+ instances = []
297
+ for raw in details_raw:
298
+ if raw:
299
+ data = raw.decode("utf-8") if isinstance(raw, bytes) else raw
300
+ instance = ServiceInstance.from_json(data)
301
+ instance.last_heartbeat = heartbeat_by_id.get(instance.id, 0)
302
+ instances.append(instance)
303
+
304
+ self._cache[service_name] = instances
305
+ self._last_refresh[service_name] = time.time()
306
+
307
+ def watch(self, service_name: str):
308
+ """Add service to background auto-refresh list."""
309
+ self._watched_services.add(service_name)
310
+ if not self._refresh_task:
311
+ self._refresh_task = asyncio.create_task(self._refresh_loop())
312
+
313
+ async def _refresh_loop(self):
314
+ """Background periodic cache refresh coroutine."""
315
+ while True:
316
+ try:
317
+ await asyncio.sleep(self.cache_interval)
318
+ for service_name in list(self._watched_services):
319
+ await self._refresh_service(
320
+ service_name, RedisKeys.SD_NO_HEALTH_CHECK
321
+ )
322
+ except asyncio.CancelledError:
323
+ break
324
+ except Exception: # pylint: disable=broad-exception-caught
325
+ await asyncio.sleep(1)
326
+
327
+ async def discover(
328
+ self,
329
+ service_name: str,
330
+ strategy: str = "random",
331
+ health_threshold_ms: int = RedisKeys.SD_DEFAULT_HEALTH_THRESHOLD_MS,
332
+ ) -> Optional[ServiceInstance]:
333
+ """Perform load-balanced discovery."""
334
+ instances = await self.get_instances(
335
+ service_name, health_threshold_ms=health_threshold_ms
336
+ )
337
+ if not instances:
338
+ return None
339
+
340
+ if strategy == "random":
341
+ return random.choice(instances)
342
+
343
+ if strategy == "round-robin":
344
+ counter = self._rr_counters.get(service_name, 0)
345
+ instance = instances[counter % len(instances)]
346
+ self._rr_counters[service_name] = counter + 1
347
+ return instance
348
+
349
+ return random.choice(instances)
350
+
351
+ async def close(self):
352
+ """Close the client, stop background tasks."""
353
+ if self._refresh_task:
354
+ self._refresh_task.cancel()
355
+ try:
356
+ await self._refresh_task
357
+ except asyncio.CancelledError:
358
+ pass
359
+ self._refresh_task = None
@@ -0,0 +1,29 @@
1
+ """
2
+ Plugin system module - Provides pluggable Worker extension mechanism.
3
+
4
+ This module implements a standardized plugin registration and management
5
+ system, allowing business logic (such as tools, prompts, skills, callbacks)
6
+ to be decoupled from Worker infrastructure, and dynamically injected and
7
+ managed through the form of plugins.
8
+ """
9
+
10
+ from .agent_config import AgentConfig, CallbackType
11
+ from .plugin import (AgentConfigsSnapshot, Plugin, PluginBuildContext,
12
+ PluginManifest, PluginReloadContext, PluginReloadResult,
13
+ PromptTemplate)
14
+ from .registry import PluginRegistry
15
+ from .trace_provider import TraceProviderFactory
16
+
17
+ __all__ = [
18
+ "AgentConfig",
19
+ "AgentConfigsSnapshot",
20
+ "CallbackType",
21
+ "PluginManifest",
22
+ "Plugin",
23
+ "PluginBuildContext",
24
+ "PluginReloadContext",
25
+ "PluginReloadResult",
26
+ "PromptTemplate",
27
+ "PluginRegistry",
28
+ "TraceProviderFactory",
29
+ ]
@@ -0,0 +1,64 @@
1
+ # pylint: disable=C0103
2
+ """
3
+ Agent configuration definitions for the plugin system.
4
+
5
+ Contains AgentConfig dataclass and CallbackType enum used to configure
6
+ agent types, tools, prompts, and callbacks.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum
11
+ from typing import Any, Callable, Literal
12
+
13
+ ConflictStrategy = Literal["error", "overwrite", "skip"]
14
+
15
+
16
+ class CallbackType(Enum):
17
+ """Callback type enum, defining callback points during agent execution.
18
+
19
+ Attributes:
20
+ before_model_callback: Triggered before model call
21
+ after_model_callback: Triggered after model call
22
+ before_tool_callback: Triggered before tool call
23
+ after_tool_callback: Triggered after tool call
24
+ before_agent_callback: Triggered before agent call
25
+ after_agent_callback: Triggered after agent call
26
+ """
27
+
28
+ before_model_callback = "before_model_callback"
29
+ after_model_callback = "after_model_callback"
30
+ before_tool_callback = "before_tool_callback"
31
+ after_tool_callback = "after_tool_callback"
32
+ before_agent_callback = "before_agent_callback"
33
+ after_agent_callback = "after_agent_callback"
34
+
35
+
36
+ @dataclass
37
+ class AgentConfig:
38
+ """Single agent capability configuration.
39
+
40
+ Attributes:
41
+ agent_id: Agent unique identifier
42
+ name: Agent name
43
+ description: Agent description
44
+ prompts: Prompt template dictionary
45
+ tools: Tool configuration dictionary
46
+ skills: Skill configuration dictionary
47
+ callbacks: Callback function dictionary
48
+ knowledge_bases: Knowledge base configuration dictionary
49
+ sub_agents: Sub-agent ID list
50
+ on_conflict: Conflict strategy: error, overwrite, or skip
51
+ extra: Extension information
52
+ """
53
+
54
+ agent_id: str
55
+ name: str = ""
56
+ description: str = ""
57
+ prompts: dict[str, Any] = field(default_factory=dict)
58
+ tools: dict[str, Any] = field(default_factory=dict)
59
+ skills: dict[str, Any] = field(default_factory=dict)
60
+ callbacks: dict[CallbackType, list[Callable]] = field(default_factory=dict)
61
+ knowledge_bases: dict[str, Any] = field(default_factory=dict)
62
+ sub_agents: list[str] = field(default_factory=list)
63
+ extra: dict[str, Any] = field(default_factory=dict)
64
+ on_conflict: ConflictStrategy = "error"