rf-agent 0.3.0__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.
agent/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """RF spectrum streaming agent."""
agent/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from agent.cli import main
2
+
3
+ main()
agent/app/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """Agent orchestrator — composition and lifecycle.
2
+
3
+ This is the top-level entry point. It wires components together,
4
+ starts concurrent tasks, and handles shutdown.
5
+
6
+ It does no real work itself — only composes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Protocol
12
+
13
+
14
+ class AgentRuntime(Protocol):
15
+ """Top-level agent lifecycle."""
16
+
17
+ async def run(self) -> None:
18
+ """Start the agent. Runs until shutdown signal.
19
+
20
+ Wiring:
21
+ source = create_source(config)
22
+ processor = create_processor(config)
23
+ transport = create_transport(config)
24
+ session = create_session(transport, codec, config)
25
+ telemetry = create_telemetry(session, config)
26
+
27
+ iq_queue = asyncio.Queue(maxsize=config.queues.iq_queue_size)
28
+ frame_queue = asyncio.Queue(maxsize=config.queues.frame_queue_size)
29
+
30
+ run concurrently:
31
+ source.run(output=iq_queue)
32
+ processor.run(input=iq_queue, output=frame_queue)
33
+ session.run(frame_queue=frame_queue)
34
+ telemetry.run()
35
+
36
+ Shutdown:
37
+ On SIGINT/SIGTERM → cancel all tasks → close transport → stop source.
38
+ """
39
+ ...
40
+
41
+ async def shutdown(self) -> None:
42
+ """Graceful shutdown. Cancel tasks, close connections, release hardware."""
43
+ ...
agent/app/factories.py ADDED
@@ -0,0 +1,126 @@
1
+ """Standard production component factories.
2
+
3
+ Wires WebSocketTransport, JsonBase64Codec, IQProcessor, Session, and
4
+ TelemetryLoop into a RunnerFactories bundle ready for AgentRunner.
5
+
6
+ A new WebSocketTransport is created per reconnect attempt; codec is shared.
7
+ Processor, session, and telemetry are constructed fresh per-attempt by the runner.
8
+
9
+ PipelineTiming and MetricsCollector are shared across attempts so cumulative
10
+ stats survive reconnects. The runner's own MetricsCollector arg in
11
+ make_telemetry is intentionally ignored in favour of the shared one.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import datetime
18
+ from collections.abc import Callable
19
+ from typing import Any
20
+
21
+ from agent.app.runner import RunnerFactories
22
+ from agent.config import AgentConfig
23
+ from agent.domain import AgentMetrics
24
+ from agent.processing import Processor
25
+ from agent.processing.processor import IQProcessor
26
+ from agent.protocol import JsonBase64Codec, ProtocolCodec
27
+ from agent.session import Session
28
+ from agent.source.base import IQSource
29
+ from agent.telemetry.loop import TelemetryLoop
30
+ from agent.telemetry.metrics import MetricsCollector
31
+ from agent.telemetry.stage_timing import PipelineTiming
32
+ from agent.transport import Transport
33
+ from agent.transport.transport import WebSocketTransport
34
+
35
+
36
+ class _TransportSender:
37
+ """Adapts (WebSocketTransport, JsonBase64Codec)
38
+ to TelemetryLoop's TelemetrySender."""
39
+
40
+ def __init__(self, transport: Transport, codec: ProtocolCodec) -> None:
41
+ self._transport = transport
42
+ self._codec = codec
43
+
44
+ async def send_heartbeat(
45
+ self, node_id: str, session_id: str, timestamp_utc: str
46
+ ) -> None:
47
+ await self._transport.send(
48
+ self._codec.encode_heartbeat(node_id, session_id, timestamp_utc)
49
+ )
50
+
51
+ async def send_agent_status(
52
+ self,
53
+ node_id: str,
54
+ session_id: str,
55
+ timestamp_utc: str,
56
+ metrics: AgentMetrics,
57
+ ) -> None:
58
+ await self._transport.send(
59
+ self._codec.encode_agent_status(node_id, session_id, timestamp_utc, metrics)
60
+ )
61
+
62
+
63
+ def make_standard_factories(
64
+ source_factory: Callable[[AgentConfig], IQSource],
65
+ ) -> RunnerFactories:
66
+ """Return a RunnerFactories bundle wired to standard production components."""
67
+ pipeline_timing = PipelineTiming()
68
+ shared_metrics = MetricsCollector(timings=pipeline_timing)
69
+
70
+ codec = JsonBase64Codec()
71
+
72
+ def make_transport(cfg: AgentConfig) -> Transport:
73
+ return WebSocketTransport()
74
+
75
+ def make_codec(cfg: AgentConfig) -> ProtocolCodec:
76
+ return codec
77
+
78
+ def make_processor(cfg: AgentConfig) -> Processor:
79
+ return IQProcessor(
80
+ descriptor=cfg.iq,
81
+ rf_config=cfg.rf,
82
+ timings=pipeline_timing,
83
+ metrics=shared_metrics,
84
+ )
85
+
86
+ def make_session(
87
+ cfg: AgentConfig,
88
+ t: Transport,
89
+ c: ProtocolCodec,
90
+ on_connected: Callable[[], None] | None = None,
91
+ ) -> Session:
92
+ return Session(
93
+ config=cfg,
94
+ transport=t,
95
+ codec=c,
96
+ timings=pipeline_timing,
97
+ metrics=shared_metrics,
98
+ on_connected=on_connected,
99
+ )
100
+
101
+ def make_telemetry(
102
+ cfg: AgentConfig,
103
+ session: Any,
104
+ _metrics: MetricsCollector,
105
+ t: Transport,
106
+ c: ProtocolCodec,
107
+ ) -> TelemetryLoop:
108
+ return TelemetryLoop(
109
+ node_id=cfg.identity.node_id,
110
+ session=session,
111
+ sender=_TransportSender(t, c),
112
+ metrics=shared_metrics,
113
+ heartbeat_interval_sec=cfg.telemetry.heartbeat_interval_s,
114
+ status_interval_sec=cfg.telemetry.status_interval_s,
115
+ clock=lambda: datetime.datetime.now(datetime.timezone.utc).isoformat(),
116
+ sleep=asyncio.sleep,
117
+ )
118
+
119
+ return RunnerFactories(
120
+ make_source=source_factory,
121
+ make_processor=make_processor,
122
+ make_transport=make_transport,
123
+ make_codec=make_codec,
124
+ make_session=make_session,
125
+ make_telemetry=make_telemetry,
126
+ )
agent/app/runner.py ADDED
@@ -0,0 +1,295 @@
1
+ """AgentRunner — top-level orchestrator.
2
+
3
+ Wires components together, manages task lifecycle, and drives the
4
+ reconnect loop. Owns queues, task startup, sibling cancellation, and
5
+ cleanup. Delegates everything else to the component it composes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import random
13
+ import sys
14
+ from collections.abc import Awaitable, Callable
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+ from typing import Any, Protocol
18
+
19
+ from agent.config import AgentConfig
20
+ from agent.domain import SpectrumFrame
21
+ from agent.processing import Processor
22
+ from agent.protocol import ProtocolCodec
23
+ from agent.source.base import IQSource
24
+ from agent.telemetry import MetricsCollector
25
+ from agent.session import FatalSessionError
26
+ from agent.transport import AuthenticationError, Transport
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Result types
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ class RunStopReason(Enum):
35
+ NORMAL_EXIT = "normal_exit" # A component returned without error
36
+ COMPONENT_FAILURE = "component_failure" # A component raised
37
+
38
+
39
+ @dataclass
40
+ class RunResult:
41
+ reason: RunStopReason
42
+ error: BaseException | None = None
43
+ connected: bool = False
44
+
45
+
46
+ class BuildFailure(Exception):
47
+ """Component construction failed. run_forever() does not retry this."""
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Telemetry runnable protocol
52
+ #
53
+ # The runner only needs run() from telemetry. Defining a minimal protocol here
54
+ # avoids coupling the runner to TelemetryLoop internals and lets tests inject
55
+ # simple fakes.
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ class _TelemetryRunnable(Protocol):
60
+ async def run(self) -> None: ...
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Factory types
65
+ # ---------------------------------------------------------------------------
66
+
67
+ SourceFactory = Callable[[AgentConfig], IQSource]
68
+ ProcessorFactory = Callable[[AgentConfig], Processor]
69
+ TransportFactory = Callable[[AgentConfig], Transport]
70
+ CodecFactory = Callable[[AgentConfig], ProtocolCodec]
71
+ SessionFactory = Callable[
72
+ [AgentConfig, Transport, ProtocolCodec, "Callable[[], None] | None"], Any
73
+ ]
74
+ TelemetryFactory = Callable[
75
+ [AgentConfig, Any, MetricsCollector, Transport, ProtocolCodec],
76
+ _TelemetryRunnable,
77
+ ]
78
+
79
+
80
+ @dataclass
81
+ class RunnerFactories:
82
+ """All component factories. Replace any factory with a fake for testing."""
83
+
84
+ make_source: SourceFactory
85
+ make_processor: ProcessorFactory
86
+ make_transport: TransportFactory
87
+ make_codec: CodecFactory
88
+ make_session: SessionFactory
89
+ make_telemetry: TelemetryFactory
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # AgentRunner
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ class AgentRunner:
98
+ """Top-level agent orchestrator.
99
+
100
+ Public API::
101
+
102
+ result = await runner.run_once() # single attempt
103
+ await runner.run_forever() # retries with backoff
104
+
105
+ Ownership:
106
+ - Creates queues, components, and tasks on every attempt.
107
+ - Cancels siblings when any task exits.
108
+ - Calls source.stop() and transport.close() in every path.
109
+ - Applies exponential backoff between retries in run_forever().
110
+
111
+ Does NOT own:
112
+ - Protocol message logic
113
+ - Handshake details
114
+ - IQ parsing / FFT
115
+ - Telemetry message schema
116
+ - Transport wire details
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ config: AgentConfig,
122
+ factories: RunnerFactories,
123
+ sleep: Callable[[float], Awaitable[None]] | None = None,
124
+ ) -> None:
125
+ self._config = config
126
+ self._factories = factories
127
+ self._sleep: Callable[[float], Awaitable[None]] = (
128
+ sleep if sleep is not None else asyncio.sleep
129
+ )
130
+
131
+ # ------------------------------------------------------------------
132
+ # run_once
133
+ # ------------------------------------------------------------------
134
+
135
+ async def run_once(self) -> RunResult:
136
+ """Build and run one agent attempt.
137
+
138
+ Returns when any component exits or fails.
139
+ Cancels sibling tasks and cleans up before returning.
140
+
141
+ Raises:
142
+ BuildFailure: if component construction raises. Never retried.
143
+ asyncio.CancelledError: on external cancellation (re-raised after
144
+ cancelling all tasks and running cleanup).
145
+ """
146
+ config = self._config
147
+ source: IQSource | None = None
148
+ transport: Transport | None = None
149
+
150
+ try:
151
+ # ---- Build phase -----------------------------------------------
152
+ # Any exception here (including source.start()) is a BuildFailure.
153
+ # We do not retry build failures.
154
+ connected_event = asyncio.Event()
155
+
156
+ def _on_connected() -> None:
157
+ print("[rf-agent] connected", file=sys.stderr)
158
+ connected_event.set()
159
+
160
+ try:
161
+ transport = self._factories.make_transport(config)
162
+ codec = self._factories.make_codec(config)
163
+ session = self._factories.make_session(
164
+ config, transport, codec, _on_connected
165
+ )
166
+ source = self._factories.make_source(config)
167
+ processor = self._factories.make_processor(config)
168
+ metrics = MetricsCollector()
169
+ telemetry = self._factories.make_telemetry(
170
+ config, session, metrics, transport, codec
171
+ )
172
+ await source.start()
173
+ except asyncio.CancelledError:
174
+ raise
175
+ except Exception as exc:
176
+ raise BuildFailure("Failed to construct agent components") from exc
177
+
178
+ # ---- Queues (fresh for every attempt) --------------------------
179
+ iq_queue: asyncio.Queue[bytes] = asyncio.Queue(
180
+ maxsize=config.queues.iq_queue_size
181
+ )
182
+ frame_queue: asyncio.Queue[SpectrumFrame] = asyncio.Queue(
183
+ maxsize=config.queues.frame_queue_size
184
+ )
185
+
186
+ # ---- Task launch -----------------------------------------------
187
+ tasks = [
188
+ asyncio.create_task(source.run(iq_queue), name="source"),
189
+ asyncio.create_task(
190
+ processor.run(iq_queue, frame_queue), name="processor"
191
+ ),
192
+ asyncio.create_task(session.run(frame_queue), name="session"),
193
+ asyncio.create_task(telemetry.run(), name="telemetry"),
194
+ ]
195
+
196
+ first_error: BaseException | None = None
197
+
198
+ try:
199
+ done, pending = await asyncio.wait(
200
+ tasks, return_when=asyncio.FIRST_COMPLETED
201
+ )
202
+
203
+ # Capture first non-cancellation error from finished tasks
204
+ for t in done:
205
+ if not t.cancelled():
206
+ task_exc = t.exception()
207
+ if task_exc is not None and first_error is None:
208
+ first_error = task_exc
209
+
210
+ # Cancel siblings
211
+ for t in pending:
212
+ t.cancel()
213
+ if pending:
214
+ await asyncio.gather(*pending, return_exceptions=True)
215
+
216
+ except asyncio.CancelledError:
217
+ # External cancellation: cancel everything, then re-raise
218
+ for t in tasks:
219
+ t.cancel()
220
+ await asyncio.gather(*tasks, return_exceptions=True)
221
+ raise
222
+
223
+ return RunResult(
224
+ reason=(
225
+ RunStopReason.COMPONENT_FAILURE
226
+ if first_error is not None
227
+ else RunStopReason.NORMAL_EXIT
228
+ ),
229
+ error=first_error,
230
+ connected=connected_event.is_set(),
231
+ )
232
+
233
+ finally:
234
+ # Cleanup runs on every exit path (success, failure, cancellation)
235
+ if source is not None:
236
+ with contextlib.suppress(Exception):
237
+ await source.stop()
238
+ if transport is not None:
239
+ with contextlib.suppress(Exception):
240
+ await transport.close()
241
+
242
+ # ------------------------------------------------------------------
243
+ # run_forever
244
+ # ------------------------------------------------------------------
245
+
246
+ async def run_forever(self) -> None:
247
+ """Run the agent, retrying after each failed attempt.
248
+
249
+ Backoff:
250
+ Starts at reconnect.initial_delay_s, multiplied by
251
+ reconnect.backoff_factor after each attempt, capped at
252
+ reconnect.max_delay_s. Jitter (±50 %) is applied when
253
+ reconnect.jitter is True.
254
+
255
+ Stops immediately on:
256
+ - asyncio.CancelledError (external cancellation)
257
+ - BuildFailure (component construction error — do not retry)
258
+ """
259
+ reconnect = self._config.reconnect
260
+ delay = reconnect.initial_delay_s
261
+
262
+ while True:
263
+ try:
264
+ result = await self.run_once()
265
+ except BuildFailure:
266
+ raise
267
+ except asyncio.CancelledError:
268
+ raise
269
+
270
+ if result.error is not None:
271
+ if isinstance(result.error, (AuthenticationError, FatalSessionError)):
272
+ raise result.error
273
+ print(
274
+ f"[rf-agent] disconnected"
275
+ f" ({type(result.error).__name__}): {result.error}",
276
+ file=sys.stderr,
277
+ )
278
+
279
+ if result.connected:
280
+ delay = reconnect.initial_delay_s
281
+
282
+ sleep_duration = delay
283
+ if reconnect.jitter:
284
+ sleep_duration = delay * (0.5 + random.random() * 0.5)
285
+
286
+ print(
287
+ f"[rf-agent] reconnecting in {sleep_duration:.1f}s...",
288
+ file=sys.stderr,
289
+ )
290
+ await self._sleep(sleep_duration)
291
+
292
+ delay = min(
293
+ delay * reconnect.backoff_factor,
294
+ reconnect.max_delay_s,
295
+ )