by-framework 0.2.2.dev5__py3-none-any.whl → 0.2.2.dev6__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/__init__.py CHANGED
@@ -5,6 +5,7 @@ Allows developers to quickly start a Redis-based agent node by inheriting
5
5
  from `GatewayWorker` and running `run_worker`.
6
6
  """
7
7
 
8
+ from .admin import WorkerManager
8
9
  from .client.byai_client import ByaiGatewayClient
9
10
  from .client.client import (
10
11
  CancelTaskResponse,
@@ -59,9 +60,12 @@ from .core.protocol.commands import (
59
60
  AskAgentCommand,
60
61
  BaseCommand,
61
62
  CancelTaskCommand,
63
+ EvictWorkerCommand,
62
64
  GatewayCommand,
63
65
  ReloadPluginsCommand,
64
66
  ResumeCommand,
67
+ ResumeWorkerCommand,
68
+ SuspendWorkerCommand,
65
69
  command_from_dict,
66
70
  get_registered_command,
67
71
  register_command,
@@ -152,6 +156,7 @@ __all__ = [
152
156
  "close_redis",
153
157
  "Redis",
154
158
  "WorkerRegistry",
159
+ "WorkerManager",
155
160
  "check_agent_type_online",
156
161
  "check_worker_online",
157
162
  "RedisKeys",
@@ -168,6 +173,9 @@ __all__ = [
168
173
  "ResumeCommand",
169
174
  "CancelTaskCommand",
170
175
  "ReloadPluginsCommand",
176
+ "SuspendWorkerCommand",
177
+ "ResumeWorkerCommand",
178
+ "EvictWorkerCommand",
171
179
  "GatewayCommand",
172
180
  "command_from_dict",
173
181
  "register_command",
@@ -0,0 +1,5 @@
1
+ """Admin-side APIs for worker lifecycle and admission control."""
2
+
3
+ from .worker_manager import WorkerManager
4
+
5
+ __all__ = ["WorkerManager"]
@@ -0,0 +1,344 @@
1
+ """by-admin — by-framework cluster admin CLI."""
2
+
3
+ # pylint: disable=global-statement
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import json
9
+ from typing import Optional
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from by_framework.admin.worker_manager import WorkerManager
16
+ from by_framework.common.redis_client import init_redis_from_url
17
+ from by_framework.core.registry import WorkerRegistry
18
+ from by_framework.metrics.snapshot import (
19
+ build_observability_snapshot,
20
+ load_history_from_redis,
21
+ )
22
+
23
+ # --------------------------------------------------------------------------- #
24
+ # App hierarchy
25
+ # --------------------------------------------------------------------------- #
26
+
27
+ app = typer.Typer(
28
+ name="by-admin",
29
+ no_args_is_help=True,
30
+ help="by-framework cluster admin CLI",
31
+ )
32
+ worker_app = typer.Typer(no_args_is_help=True)
33
+ type_app = typer.Typer(no_args_is_help=True)
34
+ metrics_app = typer.Typer(no_args_is_help=True)
35
+
36
+ app.add_typer(worker_app, name="worker", help="Worker lifecycle management")
37
+ app.add_typer(type_app, name="type", help="Agent-type admission control")
38
+ app.add_typer(metrics_app, name="metrics", help="Metrics and observability")
39
+
40
+ # --------------------------------------------------------------------------- #
41
+ # Global state + helpers
42
+ # --------------------------------------------------------------------------- #
43
+
44
+ console = Console()
45
+ err_console = Console(stderr=True)
46
+ _redis_url = "redis://localhost:6379/0"
47
+
48
+
49
+ @app.callback()
50
+ def _global(
51
+ redis_url: Optional[str] = typer.Option(
52
+ None,
53
+ "--redis-url",
54
+ envvar=["BYAI_REDIS_URL", "REDIS_URL"],
55
+ help="Redis connection URL [env: BYAI_REDIS_URL]",
56
+ ),
57
+ ):
58
+ """by-framework cluster admin CLI."""
59
+ global _redis_url
60
+ if redis_url:
61
+ _redis_url = redis_url
62
+
63
+
64
+ def _run(coro): # type: ignore[no-untyped-def]
65
+ return asyncio.run(coro)
66
+
67
+
68
+ def _die(msg: str, code: int = 1) -> None:
69
+ err_console.print(f"[red]Error:[/red] {msg}")
70
+ raise typer.Exit(code)
71
+
72
+
73
+ def _get_redis():
74
+ return init_redis_from_url(_redis_url)
75
+
76
+
77
+ # --------------------------------------------------------------------------- #
78
+ # worker subcommands
79
+ # --------------------------------------------------------------------------- #
80
+
81
+ _LIFECYCLE_COLORS = {"active": "green", "suspended": "yellow", "evicted": "red"}
82
+
83
+
84
+ @worker_app.command("list")
85
+ def worker_list(
86
+ agent_type: Optional[str] = typer.Option(
87
+ None, "--type", "-t", help="Filter by agent type"
88
+ ),
89
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
90
+ ):
91
+ """List all online workers."""
92
+
93
+ async def _inner():
94
+ registry = WorkerRegistry(_get_redis())
95
+ workers = await registry.get_all_workers()
96
+
97
+ rows = [{"worker_id": wid, **data} for wid, data in workers.items()]
98
+ if agent_type:
99
+ rows = [r for r in rows if agent_type in r.get("agent_types", [])]
100
+
101
+ if as_json:
102
+ typer.echo(json.dumps(rows, default=str))
103
+ return
104
+
105
+ if not rows:
106
+ console.print("[dim]No workers found.[/dim]")
107
+ return
108
+
109
+ table = Table(show_header=True, header_style="bold")
110
+ table.add_column("Worker ID")
111
+ table.add_column("Lifecycle")
112
+ table.add_column("Agent Types")
113
+ table.add_column("IP")
114
+ table.add_column("Last Seen (ms)")
115
+
116
+ for w in rows:
117
+ lc = w.get("lifecycle", "active")
118
+ color = _LIFECYCLE_COLORS.get(lc, "white")
119
+ table.add_row(
120
+ w["worker_id"],
121
+ f"[{color}]{lc}[/{color}]",
122
+ ", ".join(w.get("agent_types", [])),
123
+ w.get("ip_address", ""),
124
+ str(w.get("last_seen", "")),
125
+ )
126
+
127
+ console.print(table)
128
+ console.print(f"\n[dim]{len(rows)} worker(s)[/dim]")
129
+
130
+ _run(_inner())
131
+
132
+
133
+ @worker_app.command("info")
134
+ def worker_info(
135
+ worker_id: str = typer.Argument(..., help="Worker ID"),
136
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
137
+ ):
138
+ """Show detailed info for a single worker."""
139
+
140
+ async def _inner():
141
+ registry = WorkerRegistry(_get_redis())
142
+ workers = await registry.get_all_workers()
143
+ worker = workers.get(worker_id)
144
+
145
+ if worker is None:
146
+ _die(f"worker '{worker_id}' not found")
147
+ return
148
+
149
+ result = {"worker_id": worker_id, **worker}
150
+ if as_json:
151
+ typer.echo(json.dumps(result, default=str))
152
+ return
153
+
154
+ lc = result.get("lifecycle", "active")
155
+ color = _LIFECYCLE_COLORS.get(lc, "white")
156
+ lc_reason = result.get("lifecycle_reason", "")
157
+ agent_types_str = ", ".join(result.get("agent_types", []))
158
+ ip_address = result.get("ip_address", "")
159
+ last_seen = result.get("last_seen", "")
160
+ console.print(f"[bold]Worker:[/bold] {worker_id}")
161
+ console.print(f" lifecycle: [{color}]{lc}[/{color}]")
162
+ console.print(f" lifecycle_reason: {lc_reason}")
163
+ console.print(f" agent_types: {agent_types_str}")
164
+ console.print(f" ip_address: {ip_address}")
165
+ console.print(f" last_seen (ms): {last_seen}")
166
+
167
+ _run(_inner())
168
+
169
+
170
+ @worker_app.command("suspend")
171
+ def worker_suspend(
172
+ worker_id: str = typer.Argument(..., help="Worker ID"),
173
+ reason: str = typer.Option("", "--reason", "-r", help="Reason for suspension"),
174
+ ):
175
+ """Suspend a worker (stops consuming, stays online)."""
176
+
177
+ async def _inner():
178
+ mgr = WorkerManager(_get_redis())
179
+ await mgr.suspend_worker(worker_id, reason=reason)
180
+ console.print(f"[green]✓[/green] Suspended {worker_id}")
181
+
182
+ _run(_inner())
183
+
184
+
185
+ @worker_app.command("resume")
186
+ def worker_resume(
187
+ worker_id: str = typer.Argument(..., help="Worker ID"),
188
+ ):
189
+ """Resume a suspended worker."""
190
+
191
+ async def _inner():
192
+ mgr = WorkerManager(_get_redis())
193
+ await mgr.resume_worker(worker_id)
194
+ console.print(f"[green]✓[/green] Resumed {worker_id}")
195
+
196
+ _run(_inner())
197
+
198
+
199
+ @worker_app.command("evict")
200
+ def worker_evict(
201
+ worker_id: str = typer.Argument(..., help="Worker ID"),
202
+ force: bool = typer.Option(
203
+ False, "--force", help="Cancel in-flight tasks immediately"
204
+ ),
205
+ ):
206
+ """Evict a worker (clears lease, removes from routing)."""
207
+
208
+ async def _inner():
209
+ mgr = WorkerManager(_get_redis())
210
+ await mgr.evict_worker(worker_id, force=force)
211
+ console.print(f"[green]✓[/green] Evicted {worker_id}")
212
+
213
+ _run(_inner())
214
+
215
+
216
+ # --------------------------------------------------------------------------- #
217
+ # type subcommands
218
+ # --------------------------------------------------------------------------- #
219
+
220
+
221
+ @type_app.command("denylist")
222
+ def type_denylist(
223
+ agent_type: str = typer.Argument(..., help="Agent type name"),
224
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
225
+ ):
226
+ """Show all workers denied for an agent type."""
227
+
228
+ async def _inner():
229
+ mgr = WorkerManager(_get_redis())
230
+ denied = await mgr.get_type_denylist(agent_type)
231
+ if as_json:
232
+ typer.echo(json.dumps(denied))
233
+ return
234
+ if not denied:
235
+ console.print(f"[dim]No denied workers for type '{agent_type}'[/dim]")
236
+ return
237
+ for wid in denied:
238
+ console.print(f" {wid}")
239
+
240
+ _run(_inner())
241
+
242
+
243
+ @type_app.command("deny")
244
+ def type_deny(
245
+ agent_type: str = typer.Argument(..., help="Agent type name"),
246
+ worker_id: str = typer.Argument(..., help="Worker ID to deny"),
247
+ ):
248
+ """Deny a worker from consuming an agent type."""
249
+
250
+ async def _inner():
251
+ mgr = WorkerManager(_get_redis())
252
+ await mgr.deny_worker_for_type(agent_type, worker_id)
253
+ console.print(
254
+ f"[green]✓[/green] Denied {worker_id} from consuming '{agent_type}'"
255
+ )
256
+
257
+ _run(_inner())
258
+
259
+
260
+ @type_app.command("allow")
261
+ def type_allow(
262
+ agent_type: str = typer.Argument(..., help="Agent type name"),
263
+ worker_id: str = typer.Argument(..., help="Worker ID to allow"),
264
+ ):
265
+ """Remove denial for a worker on an agent type."""
266
+
267
+ async def _inner():
268
+ mgr = WorkerManager(_get_redis())
269
+ await mgr.allow_worker_for_type(agent_type, worker_id)
270
+ console.print(f"[green]✓[/green] Allowed {worker_id} on '{agent_type}'")
271
+
272
+ _run(_inner())
273
+
274
+
275
+ # --------------------------------------------------------------------------- #
276
+ # metrics subcommands
277
+ # --------------------------------------------------------------------------- #
278
+
279
+
280
+ @metrics_app.command("snapshot")
281
+ def metrics_snapshot(
282
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
283
+ ):
284
+ """Print current cluster observability snapshot."""
285
+
286
+ async def _inner():
287
+ snapshot = await build_observability_snapshot(_get_redis())
288
+ if as_json:
289
+ typer.echo(json.dumps(snapshot, default=str))
290
+ return
291
+ totals = snapshot.get("totals", {})
292
+ workers_online = totals.get("workers_online", 0)
293
+ agent_types_n = totals.get("agent_types", 0)
294
+ active_execs = totals.get("active_executions", 0)
295
+ queue_depth = snapshot.get("queue_depth_total", 0)
296
+ console.print("[bold]Cluster Snapshot[/bold]")
297
+ console.print(f" Workers online: {workers_online}")
298
+ console.print(f" Agent types: {agent_types_n}")
299
+ console.print(f" Active executions: {active_execs}")
300
+ console.print(f" Queue depth total: {queue_depth}")
301
+
302
+ _run(_inner())
303
+
304
+
305
+ @metrics_app.command("history")
306
+ def metrics_history(
307
+ limit: int = typer.Option(
308
+ 20, "--limit", "-n", help="Number of history points to show"
309
+ ),
310
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
311
+ ):
312
+ """Print recent metrics history points."""
313
+
314
+ async def _inner():
315
+ points = await load_history_from_redis(_get_redis(), limit=limit)
316
+ if as_json:
317
+ typer.echo(json.dumps(points, default=str))
318
+ return
319
+ if not points:
320
+ console.print("[dim]No history points found.[/dim]")
321
+ return
322
+ table = Table(show_header=True, header_style="bold")
323
+ table.add_column("Timestamp (ms)")
324
+ table.add_column("Workers")
325
+ table.add_column("Active")
326
+ table.add_column("Queue Depth")
327
+ for p in points:
328
+ table.add_row(
329
+ str(p.get("generated_at", "")),
330
+ str(p.get("workers_online", "")),
331
+ str(p.get("active_executions", "")),
332
+ str(p.get("queue_depth_total", "")),
333
+ )
334
+ console.print(table)
335
+
336
+ _run(_inner())
337
+
338
+
339
+ # --------------------------------------------------------------------------- #
340
+ # Entry point
341
+ # --------------------------------------------------------------------------- #
342
+
343
+ if __name__ == "__main__":
344
+ app()
@@ -0,0 +1,146 @@
1
+ """
2
+ WorkerManager — admin-side API for controlling worker lifecycle and agent-type access.
3
+
4
+ Responsibilities:
5
+ - Suspend / resume / evict individual workers (lifecycle control).
6
+ - Deny / allow workers from consuming a specific agent_type (admission control).
7
+
8
+ Lifecycle commands are delivered via two channels:
9
+ 1. Push: XADD to byai_gateway:ctrl:worker:{worker_id} (immediate delivery).
10
+ 2. Pull: HSET to byai_gateway:registry:worker:admin:{worker_id} (durable fallback,
11
+ read by the worker's heartbeat loop every heartbeat interval).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+ from typing import Any, Optional
18
+
19
+ from by_framework.common.constants import RedisKeys
20
+ from by_framework.common.redis_client import Redis, get_redis
21
+ from by_framework.core.protocol.commands import (
22
+ EvictWorkerCommand,
23
+ ResumeWorkerCommand,
24
+ SuspendWorkerCommand,
25
+ )
26
+ from by_framework.core.protocol.message_header import MessageHeader
27
+ from by_framework.core.registry import WorkerRegistry
28
+
29
+
30
+ def _admin_header() -> MessageHeader:
31
+ return MessageHeader(
32
+ session_id="admin",
33
+ trace_id=uuid.uuid4().hex,
34
+ message_id=uuid.uuid4().hex,
35
+ )
36
+
37
+
38
+ class WorkerManager:
39
+ """Admin API for worker lifecycle and agent-type admission control."""
40
+
41
+ def __init__(
42
+ self,
43
+ redis_client: Optional[Redis] = None,
44
+ registry: Optional[WorkerRegistry] = None,
45
+ ):
46
+ self.redis = redis_client or get_redis()
47
+ self.registry = registry or WorkerRegistry(self.redis)
48
+
49
+ # ------------------------------------------------------------------
50
+ # Lifecycle control
51
+ # ------------------------------------------------------------------
52
+
53
+ async def suspend_worker(self, worker_id: str, reason: str = "") -> None:
54
+ """Pause a running worker from consuming new tasks.
55
+
56
+ The worker finishes in-flight tasks but stops accepting new ones.
57
+ Immediately removes the worker from all agent_type:members sets so that
58
+ routing skips it at once; the worker re-adds itself on resume.
59
+ """
60
+ await self.registry.set_worker_admin_state(worker_id, "suspended", reason)
61
+ await self.registry.remove_worker_from_type_members(worker_id)
62
+ command = SuspendWorkerCommand(header=_admin_header(), reason=reason)
63
+ await self.redis.xadd(
64
+ RedisKeys.worker_ctrl_stream(worker_id),
65
+ command.to_redis_payload(),
66
+ )
67
+
68
+ async def resume_worker(self, worker_id: str) -> None:
69
+ """Resume a previously suspended worker.
70
+
71
+ Re-adds the worker to all agent_type:members sets immediately (respecting
72
+ the denylist), so routing can reach it again without waiting for the next
73
+ heartbeat cycle.
74
+ """
75
+ await self.registry.set_worker_admin_state(worker_id, "active", "")
76
+ await self.registry.restore_worker_to_type_members(worker_id)
77
+ command = ResumeWorkerCommand(header=_admin_header())
78
+ await self.redis.xadd(
79
+ RedisKeys.worker_ctrl_stream(worker_id),
80
+ command.to_redis_payload(),
81
+ )
82
+
83
+ async def evict_worker(
84
+ self,
85
+ worker_id: str,
86
+ *,
87
+ force: bool = False,
88
+ reason: str = "",
89
+ ) -> None:
90
+ """Shut down a worker.
91
+
92
+ Args:
93
+ worker_id: Target worker ID.
94
+ force: When True, cancels in-flight tasks immediately instead of
95
+ waiting for them to finish.
96
+ reason: Human-readable eviction reason.
97
+
98
+ Immediately removes the worker from all agent_type:members sets so
99
+ routing stops sending new messages before the heartbeat TTL expires.
100
+ """
101
+ await self.registry.set_worker_admin_state(worker_id, "evicted", reason)
102
+ await self.registry.remove_worker_from_type_members(worker_id)
103
+ command = EvictWorkerCommand(header=_admin_header(), reason=reason, force=force)
104
+ await self.redis.xadd(
105
+ RedisKeys.worker_ctrl_stream(worker_id),
106
+ command.to_redis_payload(),
107
+ )
108
+
109
+ # ------------------------------------------------------------------
110
+ # Agent-type admission control
111
+ # ------------------------------------------------------------------
112
+
113
+ async def deny_worker_for_type(self, agent_type: str, worker_id: str) -> None:
114
+ """Prevent worker_id from consuming the agent_type stream.
115
+
116
+ Takes effect on the worker's next heartbeat cycle (or immediately if
117
+ the worker checks the denylist before each XREADGROUP call).
118
+ """
119
+ await self.registry.deny_worker_for_type(agent_type, worker_id)
120
+
121
+ async def allow_worker_for_type(self, agent_type: str, worker_id: str) -> None:
122
+ """Remove worker_id from the denylist for agent_type."""
123
+ await self.registry.allow_worker_for_type(agent_type, worker_id)
124
+
125
+ async def get_type_denylist(self, agent_type: str) -> list[str]:
126
+ """Return all worker_ids currently denied for agent_type."""
127
+ return await self.registry.get_agent_type_denylist(agent_type)
128
+
129
+ # ------------------------------------------------------------------
130
+ # Status queries
131
+ # ------------------------------------------------------------------
132
+
133
+ async def get_worker_admin_state(self, worker_id: str) -> dict[str, Any]:
134
+ """Return the admin-controlled state for a worker.
135
+
136
+ Returns an empty dict when no admin state has been set (default active).
137
+ """
138
+ return await self.registry.get_worker_admin_state(worker_id)
139
+
140
+ async def clear_worker_admin_state(self, worker_id: str) -> None:
141
+ """Remove all admin state for a worker, restoring default-active behaviour."""
142
+ await self.registry.clear_worker_admin_state(worker_id)
143
+
144
+ async def allow_worker_rejoin(self, worker_id: str) -> None:
145
+ """Allow a previously evicted offline worker ID to join on next startup."""
146
+ await self.clear_worker_admin_state(worker_id)
@@ -28,7 +28,13 @@ from .config import (
28
28
  from .constants import RedisKeys
29
29
  from .emitter import (DataLayoutBuilder, DefaultSseLayoutBuilder, GatewayDataEmitter)
30
30
  from .logger import get_logger, logger, setup_logging
31
- from .redis_client import Redis, close_redis, get_redis, init_redis
31
+ from .redis_client import (
32
+ Redis,
33
+ close_redis,
34
+ get_redis,
35
+ init_redis,
36
+ init_redis_from_url,
37
+ )
32
38
 
33
39
  __all__ = [
34
40
  "RedisKeys",
@@ -37,6 +43,7 @@ __all__ = [
37
43
  "setup_logging",
38
44
  "get_redis",
39
45
  "init_redis",
46
+ "init_redis_from_url",
40
47
  "close_redis",
41
48
  "Redis",
42
49
  "DataLayoutBuilder",
@@ -135,6 +135,9 @@ class RedisKeys:
135
135
  # --- Registry ---
136
136
  # Set of known workers used for registry enumeration.
137
137
  KNOWN_WORKERS = "byai_gateway:registry:workers"
138
+ # Set of workers with explicit admin lifecycle state. Used by dashboard
139
+ # management views to include offline suspended/evicted workers.
140
+ ADMIN_WORKERS = "byai_gateway:registry:worker:admin_workers"
138
141
  WORKER_DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 5
139
142
  # 6× the heartbeat interval gives enough margin even when the main event
140
143
  # loop is briefly occupied by an LLM call. The dedicated heartbeat thread
@@ -196,6 +199,26 @@ class RedisKeys:
196
199
  """HASH mapping worker execution IDs to lightweight history snapshots."""
197
200
  return f"byai_gateway:registry:worker:history_snapshots:{worker_id}"
198
201
 
202
+ @staticmethod
203
+ def worker_admin(worker_id: str) -> str:
204
+ """HASH storing admin-controlled state for a Worker.
205
+
206
+ Fields: lifecycle (active|suspended|evicted), reason, updated_at.
207
+ Written by WorkerManager; read by the worker on heartbeat and startup.
208
+ No TTL — persists until explicitly cleared by an admin action.
209
+ """
210
+ return f"byai_gateway:registry:worker:admin:{worker_id}"
211
+
212
+ @staticmethod
213
+ def agent_type_denied(agent_type: str) -> str:
214
+ """SET of worker_ids explicitly denied from consuming an agent_type stream.
215
+
216
+ Key absent or empty SET means the agent_type is open to all workers.
217
+ Written by WorkerManager; checked by workers before XREADGROUP and
218
+ inside register_worker_membership().
219
+ """
220
+ return f"byai_gateway:registry:agent_type:denied:{agent_type}"
221
+
199
222
  @staticmethod
200
223
  def session_registry(session_id: str) -> str:
201
224
  """Session-level aggregate registry (Hash).
@@ -6,6 +6,28 @@ import sys
6
6
  from datetime import datetime
7
7
  from logging.handlers import RotatingFileHandler
8
8
 
9
+ OBSERVABILITY_LOG_FIELDS = (
10
+ "worker_id",
11
+ "message_id",
12
+ "session_id",
13
+ "trace_id",
14
+ "execution_id",
15
+ "agent_type",
16
+ "task_group_id",
17
+ "span_id",
18
+ )
19
+
20
+
21
+ def observability_log_extra(**fields: object) -> dict[str, dict[str, str]]:
22
+ """Build a logging ``extra`` payload with stable correlation field names."""
23
+ return {
24
+ "extra": {
25
+ key: str(value)
26
+ for key, value in fields.items()
27
+ if key in OBSERVABILITY_LOG_FIELDS and value not in ("", None)
28
+ }
29
+ }
30
+
9
31
 
10
32
  class ContextFilter(logging.Filter):
11
33
  """
@@ -80,16 +102,7 @@ class JSONFormatter(logging.Formatter):
80
102
  }
81
103
 
82
104
  # Add extra fields if present and non-empty
83
- for key in (
84
- "worker_id",
85
- "message_id",
86
- "session_id",
87
- "trace_id",
88
- "execution_id",
89
- "agent_type",
90
- "task_group_id",
91
- "span_id",
92
- ):
105
+ for key in OBSERVABILITY_LOG_FIELDS:
93
106
  val = getattr(record, key, None)
94
107
  if val:
95
108
  log_data[key] = val
@@ -56,6 +56,19 @@ def init_redis(
56
56
  return _redis_client
57
57
 
58
58
 
59
+ def init_redis_from_url(url: str) -> Redis:
60
+ """Initialize the global Redis singleton from a redis:// or rediss:// URL."""
61
+ global _redis_client # pylint: disable=global-statement
62
+ if _redis_client is None:
63
+ try:
64
+ _redis_client = Redis.from_url(url, decode_responses=True)
65
+ except Exception as e:
66
+ raise RedisConnectionError(
67
+ f"Failed to initialize Redis from URL: {e}"
68
+ ) from e
69
+ return _redis_client
70
+
71
+
59
72
  def get_redis() -> Redis:
60
73
  """Get the initialized global Redis client.
61
74
 
@@ -8,6 +8,7 @@ managed through the form of plugins.
8
8
  """
9
9
 
10
10
  from .agent_config import AgentConfig, CallbackType
11
+ from .agent_config_audit import build_agent_config_audit_projection
11
12
  from .plugin import (
12
13
  AgentConfigsSnapshot,
13
14
  Plugin,
@@ -24,6 +25,7 @@ __all__ = [
24
25
  "AgentConfig",
25
26
  "AgentConfigsSnapshot",
26
27
  "CallbackType",
28
+ "build_agent_config_audit_projection",
27
29
  "PluginManifest",
28
30
  "Plugin",
29
31
  "PluginBuildContext",