agenthandler 0.92.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.

Potentially problematic release.


This version of agenthandler might be problematic. Click here for more details.

agenthandler/store.py ADDED
@@ -0,0 +1,341 @@
1
+ """Durable state persistence — checkpoint / restore for long-running agents.
2
+
3
+ Pluggable storage backend via the StateStore protocol. Ships with two
4
+ implementations:
5
+
6
+ - MemoryStore — dict-backed, for testing
7
+ - SqliteStore — stdlib sqlite3, production-ready single-process default
8
+
9
+ Security notes:
10
+ - policy_dict is the ORIGINAL immutable policy set at start(). It is never
11
+ updated from agent-controlled data. The Supervisor always rebuilds from this.
12
+ - payload is agent-writable and UNTRUSTED. Treat it like user input on resume.
13
+ - circuit_breaker_states and budget counters are system-controlled (written only
14
+ by the Supervisor internals, never by agent code).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import secrets
22
+ import sqlite3
23
+ import stat
24
+ import threading
25
+ from dataclasses import asdict, dataclass, field
26
+ from datetime import datetime, timezone
27
+ from enum import Enum
28
+ from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable
29
+
30
+ # Maximum payload size in bytes (1 MB). Payloads exceeding this are rejected.
31
+ MAX_PAYLOAD_BYTES = 1_048_576
32
+
33
+
34
+ class SessionStatus(Enum):
35
+ """Lifecycle status of an agent session."""
36
+
37
+ RUNNING = "running"
38
+ PAUSED = "paused"
39
+ STOPPED = "stopped"
40
+ COMPLETED = "completed"
41
+ FAILED = "failed"
42
+
43
+
44
+ @dataclass
45
+ class Checkpoint:
46
+ """Snapshot of a session's full state — enough to rehydrate a Supervisor.
47
+
48
+ Trust levels:
49
+ - SYSTEM-CONTROLLED (written only by Supervisor/SessionManager internals):
50
+ session_id, agent_id, status, iterations, tokens_used, token_limit,
51
+ iteration_limit, circuit_breaker_states, original_policy_dict, timestamp,
52
+ created_at
53
+ - AGENT-WRITABLE (untrusted — treat like user input):
54
+ payload
55
+ """
56
+
57
+ session_id: str
58
+ agent_id: str
59
+ status: SessionStatus
60
+
61
+ # Supervisor state (system-controlled)
62
+ iterations: int = 0
63
+ tokens_used: int = 0
64
+ token_limit: Optional[int] = None
65
+ iteration_limit: int = 5
66
+ circuit_breaker_states: Dict[str, Any] = field(default_factory=dict)
67
+
68
+ # IMMUTABLE original policy set at start() — never updated from agent data
69
+ policy_dict: Dict[str, Any] = field(default_factory=dict)
70
+
71
+ # Agent-writable payload — UNTRUSTED, treat like user input on resume
72
+ payload: Dict[str, Any] = field(default_factory=dict)
73
+
74
+ timestamp: str = ""
75
+ created_at: str = "" # set once at creation, used for expiry
76
+
77
+ # If True, session is in-memory only — no auto-checkpointing, no crash recovery
78
+ stateless: bool = False
79
+
80
+ # How many times this session has been resumed (system-controlled, crash loop protection)
81
+ resume_count: int = 0
82
+
83
+ # Human-readable reason the session ended (set on stop/fail)
84
+ failure_reason: str = ""
85
+
86
+ # HMAC checksum of the original policy — verified on resume
87
+ policy_checksum: str = ""
88
+
89
+ # Persisted audit entries (list of dicts) — survives crashes
90
+ audit_log: List[Dict[str, Any]] = field(default_factory=list)
91
+
92
+ def __post_init__(self) -> None:
93
+ if not self.timestamp:
94
+ self.timestamp = datetime.now(timezone.utc).isoformat()
95
+ if not self.created_at:
96
+ self.created_at = self.timestamp
97
+ if isinstance(self.status, str):
98
+ self.status = SessionStatus(self.status)
99
+
100
+ def to_dict(self) -> Dict[str, Any]:
101
+ d = asdict(self)
102
+ d["status"] = self.status.value
103
+ return d
104
+
105
+ @classmethod
106
+ def from_dict(cls, d: Dict[str, Any]) -> "Checkpoint":
107
+ return cls(
108
+ session_id=d["session_id"],
109
+ agent_id=d["agent_id"],
110
+ status=SessionStatus(d["status"]) if isinstance(d["status"], str) else d["status"],
111
+ iterations=d.get("iterations", 0),
112
+ tokens_used=d.get("tokens_used", 0),
113
+ token_limit=d.get("token_limit"),
114
+ iteration_limit=d.get("iteration_limit", 5),
115
+ circuit_breaker_states=d.get("circuit_breaker_states", {}),
116
+ policy_dict=d.get("policy_dict", {}),
117
+ payload=d.get("payload", {}),
118
+ timestamp=d.get("timestamp", ""),
119
+ created_at=d.get("created_at", d.get("timestamp", "")),
120
+ stateless=d.get("stateless", False),
121
+ resume_count=d.get("resume_count", 0),
122
+ failure_reason=d.get("failure_reason", ""),
123
+ policy_checksum=d.get("policy_checksum", ""),
124
+ audit_log=d.get("audit_log", []),
125
+ )
126
+
127
+
128
+ def new_session_id() -> str:
129
+ """Generate a cryptographically random session ID (128 bits of entropy)."""
130
+ return secrets.token_hex(16)
131
+
132
+
133
+ def validate_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
134
+ """Validate and sanitize an agent payload before storage.
135
+
136
+ Enforces size limits and ensures the payload is safe to serialize.
137
+ Raises ValueError if the payload is invalid.
138
+ """
139
+ serialized = json.dumps(payload, default=str)
140
+ if len(serialized.encode("utf-8")) > MAX_PAYLOAD_BYTES:
141
+ raise ValueError(
142
+ f"Payload exceeds maximum size ({MAX_PAYLOAD_BYTES} bytes). "
143
+ f"Got {len(serialized.encode('utf-8'))} bytes."
144
+ )
145
+ # Re-parse to strip any non-JSON-serializable objects that default=str masked
146
+ result: Dict[str, Any] = json.loads(serialized)
147
+ return result
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # StateStore protocol
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ @runtime_checkable
156
+ class StateStore(Protocol):
157
+ """Pluggable storage backend for session checkpoints."""
158
+
159
+ def save_checkpoint(self, checkpoint: Checkpoint) -> None: ...
160
+
161
+ def load_checkpoint(self, session_id: str) -> Optional[Checkpoint]: ...
162
+
163
+ def list_sessions(self) -> List[Checkpoint]: ...
164
+
165
+ def delete_session(self, session_id: str) -> bool: ...
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # MemoryStore — dict-backed, for testing
170
+ # ---------------------------------------------------------------------------
171
+
172
+
173
+ class MemoryStore:
174
+ """In-memory checkpoint store. Thread-safe."""
175
+
176
+ def __init__(self) -> None:
177
+ self._data: Dict[str, Checkpoint] = {}
178
+ self._lock = threading.Lock()
179
+
180
+ def save_checkpoint(self, checkpoint: Checkpoint) -> None:
181
+ with self._lock:
182
+ self._data[checkpoint.session_id] = checkpoint
183
+
184
+ def load_checkpoint(self, session_id: str) -> Optional[Checkpoint]:
185
+ with self._lock:
186
+ return self._data.get(session_id)
187
+
188
+ def list_sessions(self) -> List[Checkpoint]:
189
+ with self._lock:
190
+ return list(self._data.values())
191
+
192
+ def delete_session(self, session_id: str) -> bool:
193
+ with self._lock:
194
+ if session_id in self._data:
195
+ del self._data[session_id]
196
+ return True
197
+ return False
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # SqliteStore — stdlib sqlite3, production default
202
+ # ---------------------------------------------------------------------------
203
+
204
+ _CREATE_TABLE = """\
205
+ CREATE TABLE IF NOT EXISTS checkpoints (
206
+ session_id TEXT PRIMARY KEY,
207
+ agent_id TEXT NOT NULL,
208
+ status TEXT NOT NULL,
209
+ iterations INTEGER NOT NULL DEFAULT 0,
210
+ tokens_used INTEGER NOT NULL DEFAULT 0,
211
+ token_limit INTEGER,
212
+ iteration_limit INTEGER NOT NULL DEFAULT 5,
213
+ circuit_breaker_states TEXT NOT NULL DEFAULT '{}',
214
+ policy_dict TEXT NOT NULL DEFAULT '{}',
215
+ payload TEXT NOT NULL DEFAULT '{}',
216
+ timestamp TEXT NOT NULL,
217
+ created_at TEXT NOT NULL DEFAULT '',
218
+ audit_log TEXT NOT NULL DEFAULT '[]'
219
+ )"""
220
+
221
+ # Migration: add columns if upgrading from Phase 1 schema
222
+ _MIGRATIONS = [
223
+ "ALTER TABLE checkpoints ADD COLUMN created_at TEXT NOT NULL DEFAULT ''",
224
+ "ALTER TABLE checkpoints ADD COLUMN audit_log TEXT NOT NULL DEFAULT '[]'",
225
+ ]
226
+
227
+
228
+ class SqliteStore:
229
+ """SQLite-backed checkpoint store. Thread-safe (serialized via lock).
230
+
231
+ Uses stdlib sqlite3 — no extra dependencies.
232
+ Creates the database file with owner-only permissions (0600).
233
+
234
+ Usage:
235
+ store = SqliteStore("checkpoints.db")
236
+ store.save_checkpoint(checkpoint)
237
+ """
238
+
239
+ def __init__(self, db_path: str = "agenthandler_sessions.db"):
240
+ self._db_path = db_path
241
+ self._lock = threading.Lock()
242
+
243
+ # Create file with restricted permissions if it doesn't exist
244
+ if not os.path.exists(db_path):
245
+ fd = os.open(db_path, os.O_CREAT | os.O_WRONLY, stat.S_IRUSR | stat.S_IWUSR)
246
+ os.close(fd)
247
+
248
+ with self._connect() as conn:
249
+ conn.execute(_CREATE_TABLE)
250
+ # Run migrations for existing databases
251
+ for migration in _MIGRATIONS:
252
+ try:
253
+ conn.execute(migration)
254
+ except sqlite3.OperationalError:
255
+ pass # column already exists
256
+
257
+ def _connect(self) -> sqlite3.Connection:
258
+ return sqlite3.connect(self._db_path)
259
+
260
+ def save_checkpoint(self, checkpoint: Checkpoint) -> None:
261
+ with self._lock:
262
+ with self._connect() as conn:
263
+ conn.execute(
264
+ """\
265
+ INSERT OR REPLACE INTO checkpoints
266
+ (session_id, agent_id, status, iterations, tokens_used,
267
+ token_limit, iteration_limit, circuit_breaker_states,
268
+ policy_dict, payload, timestamp, created_at, audit_log)
269
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
270
+ (
271
+ checkpoint.session_id,
272
+ checkpoint.agent_id,
273
+ checkpoint.status.value,
274
+ checkpoint.iterations,
275
+ checkpoint.tokens_used,
276
+ checkpoint.token_limit,
277
+ checkpoint.iteration_limit,
278
+ json.dumps(checkpoint.circuit_breaker_states),
279
+ json.dumps(checkpoint.policy_dict),
280
+ json.dumps(checkpoint.payload),
281
+ checkpoint.timestamp,
282
+ checkpoint.created_at,
283
+ json.dumps(checkpoint.audit_log),
284
+ ),
285
+ )
286
+
287
+ def load_checkpoint(self, session_id: str) -> Optional[Checkpoint]:
288
+ with self._lock:
289
+ with self._connect() as conn:
290
+ row = conn.execute(
291
+ "SELECT * FROM checkpoints WHERE session_id = ?", (session_id,)
292
+ ).fetchone()
293
+ if row is None:
294
+ return None
295
+ return self._row_to_checkpoint(row)
296
+
297
+ def list_sessions(self) -> List[Checkpoint]:
298
+ with self._lock:
299
+ with self._connect() as conn:
300
+ rows = conn.execute("SELECT * FROM checkpoints ORDER BY timestamp DESC").fetchall()
301
+ return [self._row_to_checkpoint(r) for r in rows]
302
+
303
+ def delete_session(self, session_id: str) -> bool:
304
+ with self._lock:
305
+ with self._connect() as conn:
306
+ cursor = conn.execute("DELETE FROM checkpoints WHERE session_id = ?", (session_id,))
307
+ return cursor.rowcount > 0
308
+
309
+ def delete_expired(self, max_age_seconds: float) -> int:
310
+ """Delete sessions older than max_age_seconds. Returns count deleted."""
311
+ cutoff = datetime.now(timezone.utc).timestamp() - max_age_seconds
312
+ with self._lock:
313
+ with self._connect() as conn:
314
+ # created_at is ISO format — compare as strings (works for ISO 8601)
315
+ cutoff_iso = datetime.fromtimestamp(cutoff, tz=timezone.utc).isoformat()
316
+ cursor = conn.execute(
317
+ "DELETE FROM checkpoints WHERE created_at != '' AND created_at < ?",
318
+ (cutoff_iso,),
319
+ )
320
+ return cursor.rowcount
321
+
322
+ @staticmethod
323
+ def _row_to_checkpoint(row: Tuple[Any, ...]) -> Checkpoint:
324
+ # Handle both old (11-column) and new (13-column) schemas
325
+ created_at = row[11] if len(row) > 11 else ""
326
+ audit_log = json.loads(row[12]) if len(row) > 12 else []
327
+ return Checkpoint(
328
+ session_id=row[0],
329
+ agent_id=row[1],
330
+ status=SessionStatus(row[2]),
331
+ iterations=row[3],
332
+ tokens_used=row[4],
333
+ token_limit=row[5],
334
+ iteration_limit=row[6],
335
+ circuit_breaker_states=json.loads(row[7]),
336
+ policy_dict=json.loads(row[8]),
337
+ payload=json.loads(row[9]),
338
+ timestamp=row[10],
339
+ created_at=created_at,
340
+ audit_log=audit_log,
341
+ )
@@ -0,0 +1,339 @@
1
+ """Streaming input connectors — Kafka, Kinesis, Redis Streams.
2
+
3
+ Subscribe an agent to a real-time event stream. Each message fires the
4
+ registered pipeline with the message payload as input.
5
+
6
+ All three connectors implement the same StreamConnector protocol:
7
+ - start() — begin consuming
8
+ - stop() — stop consuming
9
+ - Messages fire the registered callback asynchronously
10
+
11
+ Optional dependencies:
12
+ - Kafka: ``pip install aiokafka``
13
+ - Kinesis: ``pip install aioboto3``
14
+ - Redis Streams: ``pip install redis``
15
+
16
+ Usage:
17
+ from agenthandler.streams import KafkaStreamConnector
18
+
19
+ async def on_message(payload):
20
+ # Run pipeline with the payload as input
21
+ return await pipeline.run()
22
+
23
+ connector = KafkaStreamConnector(
24
+ bootstrap_servers="localhost:9092",
25
+ topic="agent-events",
26
+ group_id="my-agent",
27
+ on_message=on_message,
28
+ )
29
+ await connector.start()
30
+ # ... later ...
31
+ await connector.stop()
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import json
38
+ from typing import Any, Awaitable, Callable, Dict, Optional, Protocol, runtime_checkable
39
+
40
+ MessageHandler = Callable[[Dict[str, Any]], Awaitable[Any]]
41
+
42
+
43
+ @runtime_checkable
44
+ class StreamConnector(Protocol):
45
+ """Protocol for streaming input connectors."""
46
+
47
+ async def start(self) -> None: ...
48
+ async def stop(self) -> None: ...
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Kafka
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ class KafkaStreamConnector:
57
+ """Consume messages from a Kafka topic and fire an agent pipeline.
58
+
59
+ Uses aiokafka (optional dependency). Offsets are committed after
60
+ successful handler execution — at-least-once delivery.
61
+
62
+ Args:
63
+ bootstrap_servers: Comma-separated Kafka broker list.
64
+ topic: Topic to consume from.
65
+ group_id: Consumer group ID for offset tracking.
66
+ on_message: Async callback fired with each message payload.
67
+ auto_offset_reset: "earliest" or "latest".
68
+ value_deserializer: How to decode message bytes (default: JSON).
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ bootstrap_servers: str,
74
+ topic: str,
75
+ group_id: str,
76
+ on_message: MessageHandler,
77
+ auto_offset_reset: str = "latest",
78
+ value_deserializer: Optional[Callable[[bytes], Any]] = None,
79
+ ):
80
+ self._bootstrap = bootstrap_servers
81
+ self._topic = topic
82
+ self._group_id = group_id
83
+ self._on_message = on_message
84
+ self._auto_offset_reset = auto_offset_reset
85
+ self._deserializer = value_deserializer or (lambda b: json.loads(b.decode()))
86
+ self._consumer: Any = None
87
+ self._running = False
88
+ self._task: Optional[asyncio.Task[None]] = None
89
+
90
+ async def start(self) -> None:
91
+ """Start consuming messages."""
92
+ try:
93
+ from aiokafka import AIOKafkaConsumer # type: ignore[import-not-found]
94
+ except ImportError as e:
95
+ raise ImportError("aiokafka required: pip install aiokafka") from e
96
+
97
+ self._consumer = AIOKafkaConsumer(
98
+ self._topic,
99
+ bootstrap_servers=self._bootstrap,
100
+ group_id=self._group_id,
101
+ auto_offset_reset=self._auto_offset_reset,
102
+ value_deserializer=self._deserializer,
103
+ enable_auto_commit=False,
104
+ )
105
+ await self._consumer.start()
106
+ self._running = True
107
+ self._task = asyncio.create_task(self._consume_loop())
108
+
109
+ async def _consume_loop(self) -> None:
110
+ """Main consume loop — fires on_message for each record."""
111
+ try:
112
+ async for msg in self._consumer:
113
+ if not self._running:
114
+ break
115
+ payload = {
116
+ "key": msg.key.decode() if msg.key else None,
117
+ "value": msg.value,
118
+ "topic": msg.topic,
119
+ "partition": msg.partition,
120
+ "offset": msg.offset,
121
+ "timestamp": msg.timestamp,
122
+ }
123
+ try:
124
+ await self._on_message(payload)
125
+ await self._consumer.commit()
126
+ except Exception:
127
+ # Don't commit — message will be redelivered
128
+ pass
129
+ except Exception:
130
+ pass
131
+
132
+ async def stop(self) -> None:
133
+ """Stop consuming."""
134
+ self._running = False
135
+ if self._task:
136
+ self._task.cancel()
137
+ try:
138
+ await self._task
139
+ except (asyncio.CancelledError, Exception):
140
+ pass
141
+ if self._consumer:
142
+ await self._consumer.stop()
143
+ self._consumer = None
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # AWS Kinesis
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ class KinesisStreamConnector:
152
+ """Consume records from an AWS Kinesis Data Stream.
153
+
154
+ Uses aioboto3 (optional dependency). Polls shards in a loop and
155
+ fires on_message for each record.
156
+
157
+ Args:
158
+ stream_name: Kinesis stream name.
159
+ on_message: Async callback fired with each record payload.
160
+ region_name: AWS region.
161
+ shard_iterator_type: "LATEST" or "TRIM_HORIZON".
162
+ poll_interval: Seconds between shard polls.
163
+ """
164
+
165
+ def __init__(
166
+ self,
167
+ stream_name: str,
168
+ on_message: MessageHandler,
169
+ region_name: str = "us-east-1",
170
+ shard_iterator_type: str = "LATEST",
171
+ poll_interval: float = 1.0,
172
+ ):
173
+ self._stream = stream_name
174
+ self._on_message = on_message
175
+ self._region = region_name
176
+ self._iterator_type = shard_iterator_type
177
+ self._poll_interval = poll_interval
178
+ self._running = False
179
+ self._task: Optional[asyncio.Task[None]] = None
180
+ self._session: Any = None
181
+
182
+ async def start(self) -> None:
183
+ """Start consuming from all shards."""
184
+ try:
185
+ import aioboto3 # type: ignore[import-not-found]
186
+ except ImportError as e:
187
+ raise ImportError("aioboto3 required: pip install aioboto3") from e
188
+
189
+ self._session = aioboto3.Session()
190
+ self._running = True
191
+ self._task = asyncio.create_task(self._consume_loop())
192
+
193
+ async def _consume_loop(self) -> None:
194
+ """Poll Kinesis shards for records."""
195
+ async with self._session.client("kinesis", region_name=self._region) as client:
196
+ # Get shards
197
+ desc = await client.describe_stream(StreamName=self._stream)
198
+ shards = desc["StreamDescription"]["Shards"]
199
+
200
+ # Get shard iterators
201
+ iterators = {}
202
+ for shard in shards:
203
+ resp = await client.get_shard_iterator(
204
+ StreamName=self._stream,
205
+ ShardId=shard["ShardId"],
206
+ ShardIteratorType=self._iterator_type,
207
+ )
208
+ iterators[shard["ShardId"]] = resp["ShardIterator"]
209
+
210
+ # Poll loop
211
+ while self._running:
212
+ for shard_id, iterator in list(iterators.items()):
213
+ if iterator is None:
214
+ continue
215
+ try:
216
+ resp = await client.get_records(ShardIterator=iterator, Limit=100)
217
+ for record in resp.get("Records", []):
218
+ payload = {
219
+ "data": json.loads(record["Data"])
220
+ if isinstance(record["Data"], bytes)
221
+ else record["Data"],
222
+ "partition_key": record.get("PartitionKey"),
223
+ "sequence_number": record.get("SequenceNumber"),
224
+ "shard_id": shard_id,
225
+ }
226
+ try:
227
+ await self._on_message(payload)
228
+ except Exception:
229
+ pass
230
+ iterators[shard_id] = resp.get("NextShardIterator")
231
+ except Exception:
232
+ pass
233
+ await asyncio.sleep(self._poll_interval)
234
+
235
+ async def stop(self) -> None:
236
+ """Stop consuming."""
237
+ self._running = False
238
+ if self._task:
239
+ self._task.cancel()
240
+ try:
241
+ await self._task
242
+ except (asyncio.CancelledError, Exception):
243
+ pass
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Redis Streams
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ class RedisStreamConnector:
252
+ """Consume messages from a Redis Stream.
253
+
254
+ Uses redis-py with consumer groups for at-least-once delivery.
255
+
256
+ Args:
257
+ url: Redis connection URL (e.g., "redis://localhost:6379").
258
+ stream: Stream key name.
259
+ group: Consumer group name.
260
+ consumer: Consumer name within the group.
261
+ on_message: Async callback fired with each message.
262
+ block_ms: How long to block waiting for messages.
263
+ """
264
+
265
+ def __init__(
266
+ self,
267
+ url: str,
268
+ stream: str,
269
+ group: str,
270
+ consumer: str,
271
+ on_message: MessageHandler,
272
+ block_ms: int = 5000,
273
+ ):
274
+ self._url = url
275
+ self._stream = stream
276
+ self._group = group
277
+ self._consumer = consumer
278
+ self._on_message = on_message
279
+ self._block_ms = block_ms
280
+ self._redis: Any = None
281
+ self._running = False
282
+ self._task: Optional[asyncio.Task[None]] = None
283
+
284
+ async def start(self) -> None:
285
+ """Start consuming from the Redis stream."""
286
+ try:
287
+ import redis.asyncio as redis # type: ignore[import-not-found]
288
+ except ImportError as e:
289
+ raise ImportError("redis required: pip install redis") from e
290
+
291
+ self._redis = redis.from_url(self._url, decode_responses=True)
292
+
293
+ # Create consumer group (idempotent)
294
+ try:
295
+ await self._redis.xgroup_create(self._stream, self._group, id="$", mkstream=True)
296
+ except Exception:
297
+ pass # group already exists
298
+
299
+ self._running = True
300
+ self._task = asyncio.create_task(self._consume_loop())
301
+
302
+ async def _consume_loop(self) -> None:
303
+ """Main consume loop — uses XREADGROUP for consumer group semantics."""
304
+ while self._running:
305
+ try:
306
+ messages = await self._redis.xreadgroup(
307
+ groupname=self._group,
308
+ consumername=self._consumer,
309
+ streams={self._stream: ">"},
310
+ count=10,
311
+ block=self._block_ms,
312
+ )
313
+ for stream_name, entries in messages:
314
+ for msg_id, fields in entries:
315
+ payload = {
316
+ "id": msg_id,
317
+ "stream": stream_name,
318
+ "fields": fields,
319
+ }
320
+ try:
321
+ await self._on_message(payload)
322
+ await self._redis.xack(self._stream, self._group, msg_id)
323
+ except Exception:
324
+ pass
325
+ except Exception:
326
+ await asyncio.sleep(1)
327
+
328
+ async def stop(self) -> None:
329
+ """Stop consuming."""
330
+ self._running = False
331
+ if self._task:
332
+ self._task.cancel()
333
+ try:
334
+ await self._task
335
+ except (asyncio.CancelledError, Exception):
336
+ pass
337
+ if self._redis:
338
+ await self._redis.close()
339
+ self._redis = None