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/__init__.py +163 -0
- agenthandler/approval.py +135 -0
- agenthandler/audit.py +144 -0
- agenthandler/auth.py +188 -0
- agenthandler/budget.py +94 -0
- agenthandler/builder.py +178 -0
- agenthandler/circuit_breaker.py +88 -0
- agenthandler/connectors.py +425 -0
- agenthandler/context.py +410 -0
- agenthandler/errors.py +130 -0
- agenthandler/guardrails/__init__.py +79 -0
- agenthandler/guardrails/deterministic.py +472 -0
- agenthandler/guardrails/llm_based.py +549 -0
- agenthandler/guardrails/types.py +83 -0
- agenthandler/observe.py +525 -0
- agenthandler/openclaw.py +370 -0
- agenthandler/pipeline.py +532 -0
- agenthandler/pipeline_poml.py +593 -0
- agenthandler/policy.py +251 -0
- agenthandler/py.typed +0 -0
- agenthandler/redact.py +260 -0
- agenthandler/reflection.py +371 -0
- agenthandler/replay.py +225 -0
- agenthandler/server.py +752 -0
- agenthandler/session.py +391 -0
- agenthandler/skill.py +265 -0
- agenthandler/store.py +341 -0
- agenthandler/streams.py +339 -0
- agenthandler/supervisor.py +882 -0
- agenthandler/triggers.py +471 -0
- agenthandler-0.92.0.dist-info/METADATA +1355 -0
- agenthandler-0.92.0.dist-info/RECORD +34 -0
- agenthandler-0.92.0.dist-info/WHEEL +4 -0
- agenthandler-0.92.0.dist-info/licenses/LICENSE +201 -0
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
|
+
)
|
agenthandler/streams.py
ADDED
|
@@ -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
|