agenthacker 0.1.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.
@@ -0,0 +1,660 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 AgentHacker
3
+
4
+ """EventStore Protocol, NullStore, and PostgresStore implementations.
5
+
6
+ Defines the contract for persisting firewall events and agent invocations.
7
+ The SDK ships NullStore (no-op default) and PostgresStore (requires psycopg2).
8
+ Consumers can implement their own store (e.g. DynamoDB) without touching SDK code.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import uuid
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from datetime import datetime
17
+ from typing import Any, Protocol, runtime_checkable
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @runtime_checkable
23
+ class EventStore(Protocol):
24
+ """Contract for persisting firewall telemetry.
25
+
26
+ Two pairs of methods:
27
+ - record_* — synchronous, for scripts/tests/batch operations.
28
+ - submit_* — non-blocking fire-and-forget, for the logger in async routes.
29
+ """
30
+
31
+ def record_firewall_event(
32
+ self,
33
+ *,
34
+ timestamp: datetime,
35
+ checkpoint: str,
36
+ rule_id: str,
37
+ rule_name: str,
38
+ excerpt: str | None = None,
39
+ user_hash: str | None = None,
40
+ ip: str | None = None,
41
+ agent: str | None = None,
42
+ source: str = "runtime",
43
+ actor_role: str | None = None,
44
+ invocation_id: str | None = None,
45
+ ) -> None: ...
46
+
47
+ def record_invocation(
48
+ self,
49
+ *,
50
+ invocation_id: str,
51
+ timestamp: datetime,
52
+ user_hash: str | None = None,
53
+ question_preview: str | None = None,
54
+ blocked: bool,
55
+ checkpoint: str | None = None,
56
+ rule_id: str | None = None,
57
+ tool_calls: int = 0,
58
+ tokens: int = 0,
59
+ latency_ms: float = 0.0,
60
+ trace: Any = None,
61
+ agent: str | None = None,
62
+ source: str = "runtime",
63
+ actor_role: str | None = None,
64
+ ) -> None: ...
65
+
66
+ def submit_firewall_event(
67
+ self,
68
+ *,
69
+ timestamp: datetime,
70
+ checkpoint: str,
71
+ rule_id: str,
72
+ rule_name: str,
73
+ excerpt: str | None = None,
74
+ user_hash: str | None = None,
75
+ ip: str | None = None,
76
+ agent: str | None = None,
77
+ source: str = "runtime",
78
+ actor_role: str | None = None,
79
+ invocation_id: str | None = None,
80
+ ) -> None: ...
81
+
82
+ def submit_invocation(
83
+ self,
84
+ *,
85
+ invocation_id: str,
86
+ timestamp: datetime,
87
+ user_hash: str | None = None,
88
+ question_preview: str | None = None,
89
+ blocked: bool,
90
+ checkpoint: str | None = None,
91
+ rule_id: str | None = None,
92
+ tool_calls: int = 0,
93
+ tokens: int = 0,
94
+ latency_ms: float = 0.0,
95
+ trace: Any = None,
96
+ agent: str | None = None,
97
+ source: str = "runtime",
98
+ actor_role: str | None = None,
99
+ ) -> None: ...
100
+
101
+
102
+ class NullStore:
103
+ """Default store — all methods are no-ops. Zero overhead, no persistence."""
104
+
105
+ def record_firewall_event(self, **kwargs: Any) -> None:
106
+ pass
107
+
108
+ def record_invocation(self, **kwargs: Any) -> None:
109
+ pass
110
+
111
+ def submit_firewall_event(self, **kwargs: Any) -> None:
112
+ pass
113
+
114
+ def submit_invocation(self, **kwargs: Any) -> None:
115
+ pass
116
+
117
+ def insert_batch_firewall_events(self, rows: list[dict]) -> None:
118
+ pass
119
+
120
+ def insert_batch_invocations(self, rows: list[dict]) -> None:
121
+ pass
122
+
123
+ def upsert_risk_score(self, risk_score: Any) -> None:
124
+ pass
125
+
126
+ def get_risk_score(self, user_hash: str) -> None:
127
+ return None
128
+
129
+ def pardon_user(self, user_hash: str, hours: int = 1) -> None:
130
+ pass
131
+
132
+
133
+ # ── SQL ──────────────────────────────────────────────────────────────
134
+
135
+ _CREATE_FIREWALL_EVENTS = """
136
+ CREATE TABLE IF NOT EXISTS firewall_events (
137
+ id UUID PRIMARY KEY,
138
+ invocation_id UUID,
139
+ timestamp TIMESTAMPTZ NOT NULL,
140
+ checkpoint TEXT NOT NULL,
141
+ rule_id TEXT NOT NULL,
142
+ rule_name TEXT NOT NULL,
143
+ excerpt TEXT,
144
+ user_hash TEXT,
145
+ ip TEXT,
146
+ agent TEXT,
147
+ source TEXT NOT NULL DEFAULT 'runtime',
148
+ actor_role TEXT
149
+ )
150
+ """
151
+
152
+ _CREATE_INVOCATIONS = """
153
+ CREATE TABLE IF NOT EXISTS invocations (
154
+ id UUID PRIMARY KEY,
155
+ timestamp TIMESTAMPTZ NOT NULL,
156
+ user_hash TEXT,
157
+ question_preview TEXT,
158
+ blocked BOOLEAN NOT NULL,
159
+ checkpoint TEXT,
160
+ rule_id TEXT,
161
+ tool_calls INTEGER NOT NULL,
162
+ tokens INTEGER NOT NULL,
163
+ latency_ms DOUBLE PRECISION NOT NULL,
164
+ trace JSONB,
165
+ agent TEXT,
166
+ source TEXT NOT NULL DEFAULT 'runtime',
167
+ actor_role TEXT
168
+ )
169
+ """
170
+
171
+ _CREATE_USER_RISK_SCORES = """
172
+ CREATE TABLE IF NOT EXISTS user_risk_scores (
173
+ user_hash TEXT PRIMARY KEY,
174
+ score DOUBLE PRECISION NOT NULL,
175
+ level TEXT NOT NULL,
176
+ factors JSONB NOT NULL DEFAULT '[]',
177
+ computed_at TIMESTAMPTZ NOT NULL,
178
+ pardoned_until TIMESTAMPTZ
179
+ )
180
+ """
181
+
182
+ _ALTER_ADD_PARDONED_UNTIL = """
183
+ ALTER TABLE user_risk_scores ADD COLUMN IF NOT EXISTS pardoned_until TIMESTAMPTZ
184
+ """
185
+
186
+ _RISK_SCORE_INDEXES = [
187
+ "CREATE INDEX IF NOT EXISTS idx_urs_score ON user_risk_scores (score DESC)",
188
+ "CREATE INDEX IF NOT EXISTS idx_urs_level ON user_risk_scores (level)",
189
+ "CREATE INDEX IF NOT EXISTS idx_urs_computed_at ON user_risk_scores (computed_at)",
190
+ ]
191
+
192
+ _UPSERT_RISK_SCORE = """
193
+ INSERT INTO user_risk_scores (user_hash, score, level, factors, computed_at)
194
+ VALUES (%s, %s, %s, %s, %s)
195
+ ON CONFLICT (user_hash) DO UPDATE SET
196
+ score = EXCLUDED.score,
197
+ level = EXCLUDED.level,
198
+ factors = EXCLUDED.factors,
199
+ computed_at = EXCLUDED.computed_at
200
+ """
201
+
202
+ _SELECT_RISK_SCORE = """
203
+ SELECT user_hash, score, level, factors, computed_at
204
+ FROM user_risk_scores
205
+ WHERE user_hash = %s
206
+ """
207
+
208
+ _INDEXES = [
209
+ "CREATE INDEX IF NOT EXISTS idx_fe_timestamp ON firewall_events (timestamp)",
210
+ "CREATE INDEX IF NOT EXISTS idx_fe_invocation_id ON firewall_events (invocation_id)",
211
+ "CREATE INDEX IF NOT EXISTS idx_fe_checkpoint ON firewall_events (checkpoint)",
212
+ "CREATE INDEX IF NOT EXISTS idx_fe_rule_id ON firewall_events (rule_id)",
213
+ "CREATE INDEX IF NOT EXISTS idx_fe_user_hash ON firewall_events (user_hash)",
214
+ "CREATE INDEX IF NOT EXISTS idx_fe_agent ON firewall_events (agent)",
215
+ "CREATE INDEX IF NOT EXISTS idx_fe_source ON firewall_events (source)",
216
+ "CREATE INDEX IF NOT EXISTS idx_inv_timestamp ON invocations (timestamp)",
217
+ "CREATE INDEX IF NOT EXISTS idx_inv_user_hash ON invocations (user_hash)",
218
+ "CREATE INDEX IF NOT EXISTS idx_inv_blocked ON invocations (blocked)",
219
+ "CREATE INDEX IF NOT EXISTS idx_inv_agent ON invocations (agent)",
220
+ "CREATE INDEX IF NOT EXISTS idx_inv_source ON invocations (source)",
221
+ ]
222
+
223
+ _INSERT_FIREWALL_EVENT = """
224
+ INSERT INTO firewall_events
225
+ (id, invocation_id, timestamp, checkpoint, rule_id, rule_name,
226
+ excerpt, user_hash, ip, agent, source, actor_role)
227
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
228
+ """
229
+
230
+ _INSERT_INVOCATION = """
231
+ INSERT INTO invocations
232
+ (id, timestamp, user_hash, question_preview, blocked, checkpoint,
233
+ rule_id, tool_calls, tokens, latency_ms, trace, agent, source, actor_role)
234
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
235
+ """
236
+
237
+
238
+ # ── PostgresStore ────────────────────────────────────────────────────
239
+
240
+
241
+ class PostgresStore:
242
+ """Persists telemetry to PostgreSQL via psycopg2.
243
+
244
+ Lifecycle: __init__ (stores config) → connect() → use → close().
245
+ The constructor does NOT open a connection — call connect() after fork.
246
+ """
247
+
248
+ def __init__(
249
+ self,
250
+ dsn: str,
251
+ trace_mode: str = "blocked",
252
+ sync_commit: bool = True,
253
+ sample_rate: int = 10,
254
+ ) -> None:
255
+ self._dsn = dsn
256
+ self._trace_mode = trace_mode
257
+ self._sync_commit = sync_commit
258
+ self._sample_rate = sample_rate
259
+ self._conn: Any = None
260
+ self._executor: ThreadPoolExecutor | None = None
261
+
262
+ # ── Lifecycle ────────────────────────────────────────────────
263
+
264
+ def connect(self) -> None:
265
+ """Open connection, create tables/indexes, start write executor."""
266
+ import psycopg2
267
+
268
+ self._conn = psycopg2.connect(self._dsn)
269
+ if not self._sync_commit:
270
+ with self._conn.cursor() as cur:
271
+ cur.execute("SET synchronous_commit = off")
272
+ self._conn.commit()
273
+ self._create_tables()
274
+ self._executor = ThreadPoolExecutor(max_workers=1)
275
+
276
+ def close(self) -> None:
277
+ """Drain pending writes, then close the connection."""
278
+ if self._executor is not None:
279
+ self._executor.shutdown(wait=True)
280
+ self._executor = None
281
+ if self._conn is not None:
282
+ try:
283
+ self._conn.close()
284
+ except Exception:
285
+ pass
286
+ self._conn = None
287
+
288
+ def _reconnect(self) -> None:
289
+ """Replace a dead connection. Called on OperationalError."""
290
+ import psycopg2
291
+
292
+ if self._conn is not None:
293
+ try:
294
+ self._conn.close()
295
+ except Exception:
296
+ pass
297
+ self._conn = psycopg2.connect(self._dsn)
298
+ if not self._sync_commit:
299
+ with self._conn.cursor() as cur:
300
+ cur.execute("SET synchronous_commit = off")
301
+ self._conn.commit()
302
+
303
+ def _create_tables(self) -> None:
304
+ with self._conn.cursor() as cur:
305
+ cur.execute(_CREATE_FIREWALL_EVENTS)
306
+ cur.execute(_CREATE_INVOCATIONS)
307
+ cur.execute(_CREATE_USER_RISK_SCORES)
308
+ cur.execute(_ALTER_ADD_PARDONED_UNTIL)
309
+ for idx in _INDEXES + _RISK_SCORE_INDEXES:
310
+ cur.execute(idx)
311
+ self._conn.commit()
312
+
313
+ # ── Trace policy ─────────────────────────────────────────────
314
+
315
+ def _should_store_trace(self, blocked: bool, invocation_id: str) -> bool:
316
+ if self._trace_mode == "all":
317
+ return True
318
+ if self._trace_mode == "off":
319
+ return False
320
+ if self._trace_mode == "blocked":
321
+ return blocked
322
+ if self._trace_mode == "sampled":
323
+ if blocked:
324
+ return True
325
+ return hash(invocation_id) % self._sample_rate == 0
326
+ return blocked # unknown mode → fall back to "blocked"
327
+
328
+ # ── Internal insert helpers (run on executor thread) ─────────
329
+
330
+ def _do_insert_firewall_event(self, params: tuple) -> None:
331
+ import psycopg2
332
+
333
+ try:
334
+ with self._conn.cursor() as cur:
335
+ cur.execute(_INSERT_FIREWALL_EVENT, params)
336
+ self._conn.commit()
337
+ except (psycopg2.OperationalError, psycopg2.InterfaceError):
338
+ try:
339
+ self._conn.rollback()
340
+ except Exception:
341
+ pass
342
+ self._reconnect()
343
+ try:
344
+ with self._conn.cursor() as cur:
345
+ cur.execute(_INSERT_FIREWALL_EVENT, params)
346
+ self._conn.commit()
347
+ except Exception:
348
+ logger.warning(
349
+ "Firewall event insert failed after reconnect", exc_info=True
350
+ )
351
+ except Exception:
352
+ try:
353
+ self._conn.rollback()
354
+ except Exception:
355
+ pass
356
+ logger.warning("Firewall event insert failed", exc_info=True)
357
+
358
+ def _do_insert_invocation(self, params: tuple) -> None:
359
+ import psycopg2
360
+
361
+ try:
362
+ with self._conn.cursor() as cur:
363
+ cur.execute(_INSERT_INVOCATION, params)
364
+ self._conn.commit()
365
+ except (psycopg2.OperationalError, psycopg2.InterfaceError):
366
+ try:
367
+ self._conn.rollback()
368
+ except Exception:
369
+ pass
370
+ self._reconnect()
371
+ try:
372
+ with self._conn.cursor() as cur:
373
+ cur.execute(_INSERT_INVOCATION, params)
374
+ self._conn.commit()
375
+ except Exception:
376
+ logger.warning(
377
+ "Invocation insert failed after reconnect", exc_info=True
378
+ )
379
+ except Exception:
380
+ try:
381
+ self._conn.rollback()
382
+ except Exception:
383
+ pass
384
+ logger.warning("Invocation insert failed", exc_info=True)
385
+
386
+ # ── Done callback for submit_* futures ───────────────────────
387
+
388
+ @staticmethod
389
+ def _on_done(fut: Any) -> None:
390
+ exc = fut.exception()
391
+ if exc is not None:
392
+ logger.warning("EventStore async write failed: %s", exc)
393
+
394
+ # ── Public API: synchronous ──────────────────────────────────
395
+
396
+ def record_firewall_event(self, **kwargs: Any) -> None:
397
+
398
+ params = (
399
+ str(uuid.uuid4()),
400
+ kwargs.get("invocation_id"),
401
+ kwargs["timestamp"],
402
+ kwargs["checkpoint"],
403
+ kwargs["rule_id"],
404
+ kwargs["rule_name"],
405
+ kwargs.get("excerpt"),
406
+ kwargs.get("user_hash"),
407
+ kwargs.get("ip"),
408
+ kwargs.get("agent"),
409
+ kwargs.get("source", "runtime"),
410
+ kwargs.get("actor_role"),
411
+ )
412
+ self._do_insert_firewall_event(params)
413
+
414
+ def record_invocation(self, **kwargs: Any) -> None:
415
+ from psycopg2.extras import Json
416
+
417
+ blocked = kwargs["blocked"]
418
+ invocation_id = kwargs["invocation_id"]
419
+ trace = kwargs.get("trace")
420
+ if trace is not None and not self._should_store_trace(blocked, invocation_id):
421
+ trace = None
422
+
423
+ params = (
424
+ invocation_id,
425
+ kwargs["timestamp"],
426
+ kwargs.get("user_hash"),
427
+ kwargs.get("question_preview"),
428
+ blocked,
429
+ kwargs.get("checkpoint"),
430
+ kwargs.get("rule_id"),
431
+ kwargs.get("tool_calls", 0),
432
+ kwargs.get("tokens", 0),
433
+ kwargs.get("latency_ms", 0.0),
434
+ Json(trace) if trace is not None else None,
435
+ kwargs.get("agent"),
436
+ kwargs.get("source", "runtime"),
437
+ kwargs.get("actor_role"),
438
+ )
439
+ self._do_insert_invocation(params)
440
+
441
+ # ── Public API: fire-and-forget ──────────────────────────────
442
+
443
+ def submit_firewall_event(self, **kwargs: Any) -> None:
444
+ if self._executor is None:
445
+ return
446
+ params = (
447
+ str(uuid.uuid4()),
448
+ kwargs.get("invocation_id"),
449
+ kwargs["timestamp"],
450
+ kwargs["checkpoint"],
451
+ kwargs["rule_id"],
452
+ kwargs["rule_name"],
453
+ kwargs.get("excerpt"),
454
+ kwargs.get("user_hash"),
455
+ kwargs.get("ip"),
456
+ kwargs.get("agent"),
457
+ kwargs.get("source", "runtime"),
458
+ kwargs.get("actor_role"),
459
+ )
460
+ fut = self._executor.submit(self._do_insert_firewall_event, params)
461
+ fut.add_done_callback(self._on_done)
462
+
463
+ def submit_invocation(self, **kwargs: Any) -> None:
464
+ if self._executor is None:
465
+ return
466
+ from psycopg2.extras import Json
467
+
468
+ blocked = kwargs["blocked"]
469
+ invocation_id = kwargs["invocation_id"]
470
+ trace = kwargs.get("trace")
471
+ if trace is not None and not self._should_store_trace(blocked, invocation_id):
472
+ trace = None
473
+
474
+ params = (
475
+ invocation_id,
476
+ kwargs["timestamp"],
477
+ kwargs.get("user_hash"),
478
+ kwargs.get("question_preview"),
479
+ blocked,
480
+ kwargs.get("checkpoint"),
481
+ kwargs.get("rule_id"),
482
+ kwargs.get("tool_calls", 0),
483
+ kwargs.get("tokens", 0),
484
+ kwargs.get("latency_ms", 0.0),
485
+ Json(trace) if trace is not None else None,
486
+ kwargs.get("agent"),
487
+ kwargs.get("source", "runtime"),
488
+ kwargs.get("actor_role"),
489
+ )
490
+ fut = self._executor.submit(self._do_insert_invocation, params)
491
+ fut.add_done_callback(self._on_done)
492
+
493
+ # ── Risk score persistence ────────────────────────────────────
494
+
495
+ def upsert_risk_score(self, risk_score: Any) -> None:
496
+ """Persist a RiskScore to user_risk_scores (upsert on user_hash PK)."""
497
+ from psycopg2.extras import Json
498
+
499
+ factors_data = [
500
+ {
501
+ "name": f.name,
502
+ "description": f.description,
503
+ "contribution": f.contribution,
504
+ "signal_value": f.signal_value,
505
+ }
506
+ for f in risk_score.factors
507
+ ]
508
+ params = (
509
+ risk_score.user_hash,
510
+ risk_score.score,
511
+ risk_score.level.value,
512
+ Json(factors_data),
513
+ risk_score.computed_at,
514
+ )
515
+ try:
516
+ with self._conn.cursor() as cur:
517
+ cur.execute(_UPSERT_RISK_SCORE, params)
518
+ self._conn.commit()
519
+ except Exception:
520
+ try:
521
+ self._conn.rollback()
522
+ except Exception:
523
+ pass
524
+ logger.warning(
525
+ "upsert_risk_score failed for %s", risk_score.user_hash, exc_info=True
526
+ )
527
+
528
+ def get_risk_score(self, user_hash: str) -> Any:
529
+ """Fetch the stored RiskScore for user_hash, or None if not yet computed."""
530
+ from firewall_sdk.anomaly import RiskFactor, RiskLevel, RiskScore
531
+
532
+ try:
533
+ with self._conn.cursor() as cur:
534
+ cur.execute(_SELECT_RISK_SCORE, (user_hash,))
535
+ row = cur.fetchone()
536
+ except Exception:
537
+ logger.warning("get_risk_score failed for %s", user_hash, exc_info=True)
538
+ return None
539
+
540
+ if row is None:
541
+ return None
542
+
543
+ uh, score, level_str, factors_json, computed_at = row
544
+ factors = [
545
+ RiskFactor(
546
+ name=f["name"],
547
+ description=f["description"],
548
+ contribution=f["contribution"],
549
+ signal_value=f["signal_value"],
550
+ )
551
+ for f in (factors_json or [])
552
+ ]
553
+ return RiskScore(
554
+ user_hash=uh,
555
+ score=float(score),
556
+ level=RiskLevel(level_str),
557
+ factors=factors,
558
+ computed_at=computed_at,
559
+ )
560
+
561
+ def pardon_user(self, user_hash: str, hours: int = 1) -> None:
562
+ """Set a manual pardon on user_hash — overrides computed risk to LOW for `hours` hours."""
563
+ from datetime import timedelta, timezone
564
+
565
+ pardoned_until = datetime.now(timezone.utc) + timedelta(hours=hours)
566
+ try:
567
+ with self._conn.cursor() as cur:
568
+ cur.execute(
569
+ """
570
+ INSERT INTO user_risk_scores (user_hash, score, level, factors, computed_at, pardoned_until)
571
+ VALUES (%s, 0.0, 'LOW', '[]', NOW(), %s)
572
+ ON CONFLICT (user_hash) DO UPDATE SET pardoned_until = EXCLUDED.pardoned_until
573
+ """,
574
+ (user_hash, pardoned_until),
575
+ )
576
+ self._conn.commit()
577
+ except Exception:
578
+ try:
579
+ self._conn.rollback()
580
+ except Exception:
581
+ pass
582
+ logger.warning("pardon_user failed for %s", user_hash, exc_info=True)
583
+
584
+ # ── Public API: batch inserts (synchronous, for scripts) ─────
585
+
586
+ def insert_batch_firewall_events(self, rows: list[dict]) -> None:
587
+ from psycopg2.extras import execute_values
588
+
589
+ if not rows:
590
+ return
591
+ values = [
592
+ (
593
+ str(uuid.uuid4()),
594
+ r.get("invocation_id"),
595
+ r["timestamp"],
596
+ r["checkpoint"],
597
+ r["rule_id"],
598
+ r["rule_name"],
599
+ r.get("excerpt"),
600
+ r.get("user_hash"),
601
+ r.get("ip"),
602
+ r.get("agent"),
603
+ r.get("source", "runtime"),
604
+ r.get("actor_role"),
605
+ )
606
+ for r in rows
607
+ ]
608
+ with self._conn.cursor() as cur:
609
+ execute_values(
610
+ cur,
611
+ """INSERT INTO firewall_events
612
+ (id, invocation_id, timestamp, checkpoint, rule_id, rule_name,
613
+ excerpt, user_hash, ip, agent, source, actor_role)
614
+ VALUES %s""",
615
+ values,
616
+ )
617
+ self._conn.commit()
618
+
619
+ def insert_batch_invocations(self, rows: list[dict]) -> None:
620
+ from psycopg2.extras import execute_values, Json
621
+
622
+ if not rows:
623
+ return
624
+ values = []
625
+ for r in rows:
626
+ blocked = r["blocked"]
627
+ invocation_id = r["invocation_id"]
628
+ trace = r.get("trace")
629
+ if trace is not None and not self._should_store_trace(
630
+ blocked, invocation_id
631
+ ):
632
+ trace = None
633
+ values.append(
634
+ (
635
+ invocation_id,
636
+ r["timestamp"],
637
+ r.get("user_hash"),
638
+ r.get("question_preview"),
639
+ blocked,
640
+ r.get("checkpoint"),
641
+ r.get("rule_id"),
642
+ r.get("tool_calls", 0),
643
+ r.get("tokens", 0),
644
+ r.get("latency_ms", 0.0),
645
+ Json(trace) if trace is not None else None,
646
+ r.get("agent"),
647
+ r.get("source", "runtime"),
648
+ r.get("actor_role"),
649
+ )
650
+ )
651
+ with self._conn.cursor() as cur:
652
+ execute_values(
653
+ cur,
654
+ """INSERT INTO invocations
655
+ (id, timestamp, user_hash, question_preview, blocked, checkpoint,
656
+ rule_id, tool_calls, tokens, latency_ms, trace, agent, source, actor_role)
657
+ VALUES %s""",
658
+ values,
659
+ )
660
+ self._conn.commit()