@pentatonic-ai/ai-agent-sdk 0.10.23 → 0.10.25
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.
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/packages/memory-engine-v2/extractor-async/Dockerfile +5 -0
- package/packages/memory-engine-v2/extractor-async/backfill_structured_edges.py +181 -18
- package/packages/memory-engine-v2/extractor-async/test_backfill_structured_edges.py +41 -0
package/dist/index.cjs
CHANGED
|
@@ -878,7 +878,7 @@ function fireAndForgetEmit(clientConfig, sessionOpts, messages, result, model) {
|
|
|
878
878
|
}
|
|
879
879
|
|
|
880
880
|
// src/telemetry.js
|
|
881
|
-
var VERSION = "0.10.
|
|
881
|
+
var VERSION = "0.10.25";
|
|
882
882
|
var TELEMETRY_URL = "https://sdk-telemetry.philip-134.workers.dev";
|
|
883
883
|
function machineId() {
|
|
884
884
|
const raw = typeof process !== "undefined" ? `${process.env?.USER || process.env?.USERNAME || "u"}:${process.platform || "x"}:${process.arch || "x"}` : "browser";
|
package/dist/index.js
CHANGED
|
@@ -847,7 +847,7 @@ function fireAndForgetEmit(clientConfig, sessionOpts, messages, result, model) {
|
|
|
847
847
|
}
|
|
848
848
|
|
|
849
849
|
// src/telemetry.js
|
|
850
|
-
var VERSION = "0.10.
|
|
850
|
+
var VERSION = "0.10.25";
|
|
851
851
|
var TELEMETRY_URL = "https://sdk-telemetry.philip-134.workers.dev";
|
|
852
852
|
function machineId() {
|
|
853
853
|
const raw = typeof process !== "undefined" ? `${process.env?.USER || process.env?.USERNAME || "u"}:${process.platform || "x"}:${process.arch || "x"}` : "browser";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pentatonic-ai/ai-agent-sdk",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.25",
|
|
4
4
|
"description": "TES SDK — LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -15,5 +15,10 @@ COPY worker.py .
|
|
|
15
15
|
# container crash-loop on import at startup (observed 2026-06-08 deploy).
|
|
16
16
|
# The test_*.py files are intentionally excluded; pytest only, not runtime.
|
|
17
17
|
COPY noise_filter.py confidence.py entity_id.py sensitive_filter.py extraction_schema.py source_time.py ./
|
|
18
|
+
# SEE-490 one-off backfill — a standalone script (not a worker.py import), but it
|
|
19
|
+
# does `from worker import …` so it must sit beside worker.py in /app. #141 added
|
|
20
|
+
# the script but not this COPY, so it never reached the image and the runbook's
|
|
21
|
+
# `/app/backfill_structured_edges.py` apply step had nothing to run.
|
|
22
|
+
COPY backfill_structured_edges.py ./
|
|
18
23
|
|
|
19
24
|
CMD ["python", "worker.py"]
|
|
@@ -37,6 +37,17 @@ in the installed package first):
|
|
|
37
37
|
python backfill_structured_edges.py --arena 'pentatonic-team%' --commit-every 500
|
|
38
38
|
|
|
39
39
|
Reads `PG_DSN` from the environment (same as the worker).
|
|
40
|
+
|
|
41
|
+
Concurrent-writer contention (SEE-490 prod incident):
|
|
42
|
+
When run against PROD this backfill shares the `relationships`/`entities` rows
|
|
43
|
+
with the LIVE distiller worker, so the two writers can deadlock on the same
|
|
44
|
+
rows. A single unhandled `DeadlockDetected` used to kill the whole ~17h run
|
|
45
|
+
(the prod run died at ~78%). The write path is now wrapped in a bounded
|
|
46
|
+
retry-with-backoff loop and — crucially — the pending batch is BUFFERED
|
|
47
|
+
(computed, not yet written) so that on a deadlock we can `rollback()` and
|
|
48
|
+
re-apply the *entire* buffered batch, without re-reading from the streaming
|
|
49
|
+
cursor. Everything is idempotent (`ON CONFLICT (id) DO UPDATE`), so already
|
|
50
|
+
committed work is durable and a fresh resume simply re-does the tail.
|
|
40
51
|
"""
|
|
41
52
|
|
|
42
53
|
from __future__ import annotations
|
|
@@ -44,12 +55,33 @@ from __future__ import annotations
|
|
|
44
55
|
import argparse
|
|
45
56
|
import logging
|
|
46
57
|
import os
|
|
58
|
+
import random
|
|
47
59
|
import sys
|
|
48
60
|
import time
|
|
49
61
|
from typing import Any
|
|
50
62
|
|
|
51
63
|
import psycopg
|
|
52
64
|
|
|
65
|
+
# Transient, contention-driven failures that a retry can clear. A backfill that
|
|
66
|
+
# runs alongside the live distiller WILL hit these — Postgres picks one of two
|
|
67
|
+
# deadlocking transactions as the victim and aborts it; a serialization failure
|
|
68
|
+
# is the MVCC-level equivalent; OperationalError is the broad psycopg umbrella
|
|
69
|
+
# (dropped/again-busy connection) we also want to survive rather than abort a
|
|
70
|
+
# 17h run over. All are safe to retry here because every write is an idempotent
|
|
71
|
+
# ON CONFLICT (id) DO UPDATE upsert.
|
|
72
|
+
_RETRYABLE_ERRORS: tuple[type[BaseException], ...] = (
|
|
73
|
+
psycopg.errors.DeadlockDetected,
|
|
74
|
+
psycopg.errors.SerializationFailure,
|
|
75
|
+
psycopg.errors.OperationalError,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Bounded exponential backoff with jitter. Real wall-clock sleep is fine in this
|
|
79
|
+
# operational one-off; the *decision* helpers below are pure so they stay unit
|
|
80
|
+
# testable without a live DB.
|
|
81
|
+
_MAX_WRITE_RETRIES = 5
|
|
82
|
+
_BACKOFF_BASE_S = 0.5
|
|
83
|
+
_BACKOFF_CAP_S = 30.0
|
|
84
|
+
|
|
53
85
|
# Reuse the distiller's own functions so behaviour is byte-identical to the live
|
|
54
86
|
# path — the builders, the entity resolution/keying, and the edge upsert.
|
|
55
87
|
from worker import ( # type: ignore
|
|
@@ -111,6 +143,116 @@ def _source_filter(sources_arg: str) -> list[str]:
|
|
|
111
143
|
return out
|
|
112
144
|
|
|
113
145
|
|
|
146
|
+
def _is_retryable(exc: BaseException) -> bool:
|
|
147
|
+
"""True iff `exc` is a transient contention failure worth retrying.
|
|
148
|
+
|
|
149
|
+
Pure predicate (no I/O) so the retry decision is unit-testable without a DB.
|
|
150
|
+
Retryable types are the psycopg contention errors captured in
|
|
151
|
+
`_RETRYABLE_ERRORS`; anything else (a real data/logic error) must propagate.
|
|
152
|
+
"""
|
|
153
|
+
return isinstance(exc, _RETRYABLE_ERRORS)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _should_retry(exc: BaseException, attempt: int, max_retries: int = _MAX_WRITE_RETRIES) -> bool:
|
|
157
|
+
"""Decide retry-vs-raise for `exc` on `attempt` (1-based) of a batch write.
|
|
158
|
+
|
|
159
|
+
Retry only when the error is contention-driven AND we have attempts left.
|
|
160
|
+
`attempt` counts the try that just failed, so attempt N == max_retries is the
|
|
161
|
+
last one we allow before giving up (re-raise). Pure — no sleeping, no I/O.
|
|
162
|
+
"""
|
|
163
|
+
return _is_retryable(exc) and attempt < max_retries
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _backoff_seconds(
|
|
167
|
+
attempt: int,
|
|
168
|
+
base: float = _BACKOFF_BASE_S,
|
|
169
|
+
cap: float = _BACKOFF_CAP_S,
|
|
170
|
+
jitter: float | None = None,
|
|
171
|
+
) -> float:
|
|
172
|
+
"""Exponential backoff (base * 2**(attempt-1)), capped, plus small jitter.
|
|
173
|
+
|
|
174
|
+
`attempt` is 1-based. Jitter defaults to a random fraction of the step so
|
|
175
|
+
contending writers de-sync; pass an explicit `jitter` in [0,1) for
|
|
176
|
+
deterministic tests. Pure aside from the optional RNG draw.
|
|
177
|
+
"""
|
|
178
|
+
step = min(base * (2 ** max(attempt - 1, 0)), cap)
|
|
179
|
+
if jitter is None:
|
|
180
|
+
jitter = random.random()
|
|
181
|
+
return step + jitter * base
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _apply_event_edges(
|
|
185
|
+
write_conn: "psycopg.Connection[Any]",
|
|
186
|
+
item: dict[str, Any],
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Upsert one buffered edge-producing event's entities + relationships.
|
|
189
|
+
|
|
190
|
+
Uses the distiller's own upsert helpers so behaviour matches the live path.
|
|
191
|
+
Called inside the retry loop, so it must be safe to re-run after a rollback —
|
|
192
|
+
it is, because both upserts are idempotent ON CONFLICT (id) DO UPDATE.
|
|
193
|
+
"""
|
|
194
|
+
name_to_id = upsert_entities(
|
|
195
|
+
write_conn,
|
|
196
|
+
item["arena"],
|
|
197
|
+
item["event_id"],
|
|
198
|
+
item["pset"],
|
|
199
|
+
item["disc"],
|
|
200
|
+
item["det_ents"],
|
|
201
|
+
item["event_time"],
|
|
202
|
+
item["attrs"],
|
|
203
|
+
)
|
|
204
|
+
upsert_relationships(
|
|
205
|
+
write_conn,
|
|
206
|
+
item["arena"],
|
|
207
|
+
item["event_id"],
|
|
208
|
+
item["pset"],
|
|
209
|
+
item["disc"],
|
|
210
|
+
item["det_rels"],
|
|
211
|
+
name_to_id,
|
|
212
|
+
item["event_time"],
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _commit_batch_with_retry(
|
|
217
|
+
write_conn: "psycopg.Connection[Any]",
|
|
218
|
+
batch: list[dict[str, Any]],
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Apply the whole buffered `batch` and commit, retrying on contention.
|
|
221
|
+
|
|
222
|
+
The batch is re-applied FROM ITS BUFFER on every attempt — on a deadlock we
|
|
223
|
+
`rollback()` (discarding the partially-applied txn) and replay the entire
|
|
224
|
+
buffered batch, so the streaming READ cursor never has to rewind. Bounded by
|
|
225
|
+
`_MAX_WRITE_RETRIES`; a non-retryable error, or exhausted retries, re-raises.
|
|
226
|
+
"""
|
|
227
|
+
if not batch:
|
|
228
|
+
return
|
|
229
|
+
lo = batch[0]["event_id"]
|
|
230
|
+
hi = batch[-1]["event_id"]
|
|
231
|
+
attempt = 0
|
|
232
|
+
while True:
|
|
233
|
+
attempt += 1
|
|
234
|
+
try:
|
|
235
|
+
for item in batch:
|
|
236
|
+
_apply_event_edges(write_conn, item)
|
|
237
|
+
write_conn.commit()
|
|
238
|
+
return
|
|
239
|
+
except Exception as exc: # noqa: BLE001 — classified by _should_retry
|
|
240
|
+
try:
|
|
241
|
+
write_conn.rollback()
|
|
242
|
+
except Exception: # rollback best-effort; don't mask the original
|
|
243
|
+
log.warning("rollback after write error also failed", exc_info=True)
|
|
244
|
+
if not _should_retry(exc, attempt):
|
|
245
|
+
raise
|
|
246
|
+
delay = _backoff_seconds(attempt)
|
|
247
|
+
log.warning(
|
|
248
|
+
"write contention (%s) on batch events %s..%s (%d edge-events); "
|
|
249
|
+
"retry attempt %d/%d after %.2fs backoff",
|
|
250
|
+
type(exc).__name__, lo, hi, len(batch),
|
|
251
|
+
attempt, _MAX_WRITE_RETRIES, delay,
|
|
252
|
+
)
|
|
253
|
+
time.sleep(delay)
|
|
254
|
+
|
|
255
|
+
|
|
114
256
|
def main(argv: list[str]) -> int:
|
|
115
257
|
args = _parse_args(argv)
|
|
116
258
|
dsn = os.environ.get("PG_DSN")
|
|
@@ -125,8 +267,11 @@ def main(argv: list[str]) -> int:
|
|
|
125
267
|
|
|
126
268
|
# Separate connections: a server-side (named) cursor STREAMS the read so we
|
|
127
269
|
# never load the whole events table into memory; a second connection does the
|
|
128
|
-
# batched writes.
|
|
129
|
-
|
|
270
|
+
# batched writes. The read connection MUST NOT be autocommit — psycopg3's
|
|
271
|
+
# named server-side cursor issues DECLARE CURSOR, which requires a
|
|
272
|
+
# transaction block ("DECLARE CURSOR can only be used in transaction
|
|
273
|
+
# blocks"). It's read-only (SELECT), never committed, just closed at the end.
|
|
274
|
+
read_conn = psycopg.connect(dsn)
|
|
130
275
|
write_conn = psycopg.connect(dsn)
|
|
131
276
|
|
|
132
277
|
scanned = 0
|
|
@@ -134,7 +279,12 @@ def main(argv: list[str]) -> int:
|
|
|
134
279
|
edges_total = 0
|
|
135
280
|
ents_total = 0
|
|
136
281
|
t0 = time.perf_counter()
|
|
137
|
-
|
|
282
|
+
# Buffer of edge-producing events computed since the last successful commit.
|
|
283
|
+
# We hold the computed upsert args (NOT yet written) so that on a deadlock we
|
|
284
|
+
# can rollback the write txn and replay the whole batch without rewinding the
|
|
285
|
+
# streaming read cursor. Flushed (applied + committed, with retry) every
|
|
286
|
+
# --commit-every edge-producing events.
|
|
287
|
+
pending_batch: list[dict[str, Any]] = []
|
|
138
288
|
|
|
139
289
|
try:
|
|
140
290
|
with read_conn.cursor(name="backfill_events") as cur:
|
|
@@ -174,18 +324,22 @@ def main(argv: list[str]) -> int:
|
|
|
174
324
|
pset = participant_set or [arena]
|
|
175
325
|
disc = disclosure or "private"
|
|
176
326
|
event_time = event_source_time({"attributes": attrs}) or emitted_at
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
327
|
+
# Buffer the computed upsert args; do NOT touch the write conn
|
|
328
|
+
# yet. The whole buffer is applied + committed together (with
|
|
329
|
+
# deadlock retry) at flush time.
|
|
330
|
+
pending_batch.append({
|
|
331
|
+
"arena": arena,
|
|
332
|
+
"event_id": event_id,
|
|
333
|
+
"pset": pset,
|
|
334
|
+
"disc": disc,
|
|
335
|
+
"det_ents": det_ents,
|
|
336
|
+
"det_rels": det_rels,
|
|
337
|
+
"event_time": event_time,
|
|
338
|
+
"attrs": attrs,
|
|
339
|
+
})
|
|
340
|
+
if len(pending_batch) >= args.commit_every:
|
|
341
|
+
_commit_batch_with_retry(write_conn, pending_batch)
|
|
342
|
+
pending_batch = []
|
|
189
343
|
|
|
190
344
|
if with_edges % 5000 == 0 and with_edges:
|
|
191
345
|
rate = scanned / max(time.perf_counter() - t0, 1e-6)
|
|
@@ -195,11 +349,20 @@ def main(argv: list[str]) -> int:
|
|
|
195
349
|
)
|
|
196
350
|
|
|
197
351
|
if not args.dry_run:
|
|
198
|
-
|
|
352
|
+
# Flush the final partial batch (same retry-on-contention path).
|
|
353
|
+
_commit_batch_with_retry(write_conn, pending_batch)
|
|
354
|
+
pending_batch = []
|
|
199
355
|
except Exception:
|
|
200
356
|
if not args.dry_run:
|
|
201
|
-
|
|
202
|
-
|
|
357
|
+
try:
|
|
358
|
+
write_conn.rollback()
|
|
359
|
+
except Exception:
|
|
360
|
+
log.warning("final rollback also failed", exc_info=True)
|
|
361
|
+
log.exception(
|
|
362
|
+
"backfill failed; write txn rolled back. Already-committed batches are "
|
|
363
|
+
"durable/idempotent — re-run to resume (ON CONFLICT makes it a no-op "
|
|
364
|
+
"over prior work)."
|
|
365
|
+
)
|
|
203
366
|
return 1
|
|
204
367
|
finally:
|
|
205
368
|
read_conn.close()
|
|
@@ -55,3 +55,44 @@ def test_dry_run_and_sources_flags():
|
|
|
55
55
|
assert args.dry_run is True
|
|
56
56
|
assert args.limit == 10
|
|
57
57
|
assert bf._source_filter(args.sources) == ["pip-gmail-ingest"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- retry / backoff decision (SEE-490 prod deadlock survival) ----------------
|
|
61
|
+
# Pure-logic guards for the contention-retry path. The live DB replay is
|
|
62
|
+
# exercised by the run itself; here we pin the retry-vs-raise decision and the
|
|
63
|
+
# backoff schedule so a regression in either is caught without a live DB.
|
|
64
|
+
|
|
65
|
+
def test_deadlock_is_retryable_but_bounded():
|
|
66
|
+
# A deadlock (the exact prod failure) is retried while attempts remain and
|
|
67
|
+
# re-raised once they're exhausted.
|
|
68
|
+
exc = bf.psycopg.errors.DeadlockDetected("deadlock detected")
|
|
69
|
+
assert bf._is_retryable(exc) is True
|
|
70
|
+
assert bf._should_retry(exc, attempt=1, max_retries=5) is True
|
|
71
|
+
assert bf._should_retry(exc, attempt=4, max_retries=5) is True
|
|
72
|
+
# attempt == max_retries is the last allowed try; no further retry.
|
|
73
|
+
assert bf._should_retry(exc, attempt=5, max_retries=5) is False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_serialization_and_operational_are_retryable():
|
|
77
|
+
assert bf._is_retryable(bf.psycopg.errors.SerializationFailure("s")) is True
|
|
78
|
+
assert bf._is_retryable(bf.psycopg.errors.OperationalError("o")) is True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_non_contention_error_never_retries():
|
|
82
|
+
# A logic/data error (not contention) must propagate immediately, never retry.
|
|
83
|
+
exc = ValueError("bad data")
|
|
84
|
+
assert bf._is_retryable(exc) is False
|
|
85
|
+
assert bf._should_retry(exc, attempt=1, max_retries=5) is False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_backoff_is_exponential_capped_and_deterministic_with_fixed_jitter():
|
|
89
|
+
# With jitter pinned to 0 the schedule is pure exponential: base * 2**(n-1).
|
|
90
|
+
base, cap = 0.5, 30.0
|
|
91
|
+
assert bf._backoff_seconds(1, base=base, cap=cap, jitter=0.0) == 0.5
|
|
92
|
+
assert bf._backoff_seconds(2, base=base, cap=cap, jitter=0.0) == 1.0
|
|
93
|
+
assert bf._backoff_seconds(3, base=base, cap=cap, jitter=0.0) == 2.0
|
|
94
|
+
# Later attempts saturate at the cap (+ the deterministic jitter term).
|
|
95
|
+
assert bf._backoff_seconds(20, base=base, cap=cap, jitter=0.0) == cap
|
|
96
|
+
# Jitter adds at most `base`, keeping the value within [step, step+base).
|
|
97
|
+
jittered = bf._backoff_seconds(1, base=base, cap=cap, jitter=0.5)
|
|
98
|
+
assert 0.5 <= jittered < 0.5 + base
|