@pentatonic-ai/ai-agent-sdk 0.10.31 → 0.10.32
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/compat/server.py +27 -0
- package/packages/memory-engine-v2/org-model/migrations/011_event_distillations_time_index.sql +26 -0
- package/packages/memory-engine-v2/scripts/prune_distillation_queue.py +96 -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.32";
|
|
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.32";
|
|
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.32",
|
|
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",
|
|
@@ -57,6 +57,8 @@ NV_EMBED_URL = os.environ.get("NV_EMBED_URL", "http://nv-embed:8041/v1/embedding
|
|
|
57
57
|
NV_EMBED_API_KEY = os.environ.get("NV_EMBED_API_KEY", "")
|
|
58
58
|
NV_EMBED_PROVIDER = os.environ.get("NV_EMBED_PROVIDER", "openai") # 'openai' | 'pentatonic-gateway'
|
|
59
59
|
EMBED_DIM = int(os.environ.get("EMBED_DIM", "4096"))
|
|
60
|
+
# Window (minutes) over which /health/deep computes the distillation drain rate.
|
|
61
|
+
HEALTH_DRAIN_WINDOW_MIN = int(os.environ.get("HEALTH_DRAIN_WINDOW_MIN", "5"))
|
|
60
62
|
|
|
61
63
|
COLLECTION_NAME = "evidence"
|
|
62
64
|
|
|
@@ -665,6 +667,31 @@ async def health_deep():
|
|
|
665
667
|
except Exception as e:
|
|
666
668
|
result["stores"]["org_model"] = {"status": "error", "error": str(e)}
|
|
667
669
|
|
|
670
|
+
# distillation drain rate — events distilled per minute over a recent
|
|
671
|
+
# window, by producer. Surfaces whether the async extractor fleet is keeping
|
|
672
|
+
# up with ingest (pair with distillation_pending in the counts above). Cheap:
|
|
673
|
+
# served by idx_event_distillations_producer_time (producer, distilled_at).
|
|
674
|
+
try:
|
|
675
|
+
async with _pool.connection() as conn:
|
|
676
|
+
async with conn.cursor() as cur:
|
|
677
|
+
await cur.execute(
|
|
678
|
+
"SELECT producer, count(*) AS n FROM event_distillations "
|
|
679
|
+
"WHERE distilled_at > now() - make_interval(mins => %s) "
|
|
680
|
+
"GROUP BY producer",
|
|
681
|
+
(HEALTH_DRAIN_WINDOW_MIN,),
|
|
682
|
+
)
|
|
683
|
+
rows = await cur.fetchall()
|
|
684
|
+
by_producer = {r["producer"]: r["n"] for r in rows}
|
|
685
|
+
total = sum(by_producer.values())
|
|
686
|
+
result["distillation"] = {
|
|
687
|
+
"window_min": HEALTH_DRAIN_WINDOW_MIN,
|
|
688
|
+
"drain_per_min": round(total / HEALTH_DRAIN_WINDOW_MIN, 1),
|
|
689
|
+
"drained_in_window": total,
|
|
690
|
+
"by_producer": by_producer,
|
|
691
|
+
}
|
|
692
|
+
except Exception as e:
|
|
693
|
+
result["distillation"] = {"status": "error", "error": str(e)}
|
|
694
|
+
|
|
668
695
|
# vector-index
|
|
669
696
|
try:
|
|
670
697
|
info = await _qdrant.get_collection(COLLECTION_NAME)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- 011_event_distillations_time_index.sql — time-ordered index for drain-rate
|
|
2
|
+
-- queries over the distillation ledger.
|
|
3
|
+
--
|
|
4
|
+
-- /health/deep surfaces the distillation drain rate (events distilled per
|
|
5
|
+
-- minute, by producer) so we can see whether the async extractor fleet is
|
|
6
|
+
-- keeping up with ingest. That query is a recent-window scan across ALL
|
|
7
|
+
-- producers:
|
|
8
|
+
--
|
|
9
|
+
-- SELECT producer, count(*) FROM event_distillations
|
|
10
|
+
-- WHERE distilled_at > now() - interval '5 minutes' GROUP BY producer;
|
|
11
|
+
--
|
|
12
|
+
-- The existing idx_event_distillations_producer_time is (producer, distilled_at)
|
|
13
|
+
-- — producer-leading, so it cannot serve a cross-producer time-range scan, and
|
|
14
|
+
-- the planner falls back to a Seq Scan (measured 244ms at 420k rows). The ledger
|
|
15
|
+
-- is an append-only, unbounded log growing ~80k rows/day, so that scan degrades
|
|
16
|
+
-- linearly. This time-leading index makes the drain query an index range scan
|
|
17
|
+
-- (producer trails so the GROUP BY is covered) and keeps it flat as the ledger
|
|
18
|
+
-- grows. Also serves any other "recent distillations" time-range query.
|
|
19
|
+
--
|
|
20
|
+
-- On an already-initialised prod DB (migrations mount at
|
|
21
|
+
-- /docker-entrypoint-initdb.d and only run on a fresh volume) apply this
|
|
22
|
+
-- manually with CREATE INDEX CONCURRENTLY to avoid locking the ledger against
|
|
23
|
+
-- live writes.
|
|
24
|
+
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_event_distillations_distilled_at
|
|
26
|
+
ON event_distillations (distilled_at DESC, producer);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""prune_distillation_queue.py — batched prune of old terminal-state rows from
|
|
3
|
+
distillation_queue.
|
|
4
|
+
|
|
5
|
+
The queue accumulates one row per distillation ATTEMPT and never prunes: done
|
|
6
|
+
rows pile up (992k of ~1.08M rows measured 2026-07-09), and the redistill
|
|
7
|
+
gardener inserts a fresh row per re-distill (no unique constraint on event_id).
|
|
8
|
+
This bloats idx_distillation_status, so once the workers got fast (after the
|
|
9
|
+
provenance cap) every `claim_next_batch` scan and every mark-done UPDATE started
|
|
10
|
+
taking 10-12s — the queue became the next drain bottleneck.
|
|
11
|
+
|
|
12
|
+
A `done` row's ONLY consumer is the ingest idempotency guard in
|
|
13
|
+
_enqueue_distillation (`NOT EXISTS ... status IN ('pending','claimed','done')`),
|
|
14
|
+
which stops a RE-DELIVERED event being distilled twice. Re-delivery only happens
|
|
15
|
+
for recent events (ingest retries / DLQ replays), so done rows older than a
|
|
16
|
+
safety window are pure tombstones. The redistill gardener does NOT read done rows
|
|
17
|
+
(its "already queued" guard checks only pending/claimed; its "already clean" gate
|
|
18
|
+
reads distillation_traces + event_distillations) — verified 2026-07-09. So
|
|
19
|
+
pruning old terminal rows is safe and does not trigger re-distillation.
|
|
20
|
+
|
|
21
|
+
Deletes in bounded batches (commit per batch) so it never holds a long lock or a
|
|
22
|
+
huge transaction against live distillation / replication. Idempotent + resumable.
|
|
23
|
+
|
|
24
|
+
DRY-RUN by default (reports how many rows match, writes nothing). --apply to delete.
|
|
25
|
+
|
|
26
|
+
python3 prune_distillation_queue.py --older-than-days 14 # dry-run
|
|
27
|
+
python3 prune_distillation_queue.py --older-than-days 14 --apply
|
|
28
|
+
|
|
29
|
+
Only 'done' by default. 'failed' rows are kept (few; useful for investigation)
|
|
30
|
+
unless --include-failed. After a large prune, plain autovacuum reclaims the space
|
|
31
|
+
for reuse (table stops growing); returning it to disk needs VACUUM/pg_repack.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import os
|
|
37
|
+
import time
|
|
38
|
+
|
|
39
|
+
import psycopg
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main() -> int:
|
|
43
|
+
ap = argparse.ArgumentParser()
|
|
44
|
+
ap.add_argument("--older-than-days", type=int, default=14,
|
|
45
|
+
help="prune terminal rows whose completed_at (or enqueued_at) is older than this")
|
|
46
|
+
ap.add_argument("--batch", type=int, default=10000, help="rows deleted per commit")
|
|
47
|
+
ap.add_argument("--include-failed", action="store_true",
|
|
48
|
+
help="also prune old 'failed' rows (default: 'done' only)")
|
|
49
|
+
ap.add_argument("--apply", action="store_true", help="delete (default: dry-run)")
|
|
50
|
+
args = ap.parse_args()
|
|
51
|
+
|
|
52
|
+
statuses = ["done"] + (["failed"] if args.include_failed else [])
|
|
53
|
+
# age is measured on completion time, falling back to enqueue time for legacy
|
|
54
|
+
# rows written before completed_at existed.
|
|
55
|
+
where = (
|
|
56
|
+
"status = ANY(%(statuses)s) "
|
|
57
|
+
"AND COALESCE(completed_at, enqueued_at) < now() - make_interval(days => %(days)s)"
|
|
58
|
+
)
|
|
59
|
+
params = {"statuses": statuses, "days": args.older_than_days}
|
|
60
|
+
|
|
61
|
+
with psycopg.connect(os.environ["PG_DSN"], autocommit=True) as conn:
|
|
62
|
+
total = conn.execute(
|
|
63
|
+
f"SELECT count(*) FROM distillation_queue WHERE {where}", params
|
|
64
|
+
).fetchone()[0]
|
|
65
|
+
overall = conn.execute("SELECT count(*) FROM distillation_queue").fetchone()[0]
|
|
66
|
+
print(f"matched {total} prunable row(s) (statuses={statuses}, "
|
|
67
|
+
f"older_than={args.older_than_days}d) of {overall} total")
|
|
68
|
+
if not args.apply:
|
|
69
|
+
print("dry-run — pass --apply to delete")
|
|
70
|
+
return 0
|
|
71
|
+
if total == 0:
|
|
72
|
+
print("nothing to prune")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
deleted = 0
|
|
76
|
+
t0 = time.monotonic()
|
|
77
|
+
while True:
|
|
78
|
+
# Delete by PK from a bounded id subset so each statement is a small,
|
|
79
|
+
# index-driven batch — never a full-table lock.
|
|
80
|
+
n = conn.execute(
|
|
81
|
+
f"DELETE FROM distillation_queue WHERE id IN "
|
|
82
|
+
f"(SELECT id FROM distillation_queue WHERE {where} LIMIT %(batch)s)",
|
|
83
|
+
{**params, "batch": args.batch},
|
|
84
|
+
).rowcount
|
|
85
|
+
deleted += n
|
|
86
|
+
print(f" deleted {deleted}/{total} ({time.monotonic()-t0:.0f}s)", flush=True)
|
|
87
|
+
if n == 0:
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
remaining = conn.execute("SELECT count(*) FROM distillation_queue").fetchone()[0]
|
|
91
|
+
print(f"done: pruned {deleted} row(s); distillation_queue now {remaining} rows")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
raise SystemExit(main())
|