@pentatonic-ai/ai-agent-sdk 0.10.31 → 0.10.33

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 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.31";
881
+ var VERSION = "0.10.33";
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.31";
850
+ var VERSION = "0.10.33";
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.31",
3
+ "version": "0.10.33",
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)
@@ -193,13 +193,24 @@ _PROV_REL = _cap_prov("relationships.provenance_event_ids", "EXCLUDED.provenance
193
193
  # lever, this is the monitoring + active-learning sample (drift detection and
194
194
  # fresh teacher-gold on live traffic). Default conservative-ish 5%.
195
195
  STUDENT_SAMPLE_RATE = float(os.environ.get("STUDENT_SAMPLE_RATE", "0.05"))
196
- # Fact categories that always go to the teacher regardless of the student's
197
- # output (high-value, cheap to over-escalate). Comma-separated, lowercased.
196
+ # Fact categories that go to the teacher regardless of the student's output
197
+ # (high-value, cheap to over-escalate). Comma-separated, lowercased.
198
198
  HIGH_VALUE_CATEGORIES = {
199
199
  c.strip().lower()
200
200
  for c in os.environ.get("HIGH_VALUE_CATEGORIES", "decision,commitment").split(",")
201
201
  if c.strip()
202
202
  }
203
+ # Fraction of high-value-class events that ESCALATE to the teacher. 1.0 (default)
204
+ # = the original always-teacher gate. The gate was set at cascade launch when the
205
+ # student was unproven; since then the clean student has zero quality-fail
206
+ # escalations in prod and shadow-eval parity with the teacher — while the HV gate
207
+ # routed ~57% of a decision/commitment-heavy backlog to the teacher (90% of all
208
+ # escalations, 2026-07-09), pinning drain to teacher capacity with the student
209
+ # idle. Lowering this (e.g. 0.25) makes HV student-primary with an elevated
210
+ # teacher sample as the safety net/monitoring corpus on exactly the high-value
211
+ # class. Instant rollback: set back to 1.0. Escalated-anyway HV events keep the
212
+ # 'high_value_class' ledger reason, so the mix stays queryable.
213
+ HIGH_VALUE_SAMPLE_RATE = float(os.environ.get("HIGH_VALUE_SAMPLE_RATE", "1.0"))
203
214
 
204
215
 
205
216
  # KV-text output format constants. We dropped JSON output (and the
@@ -1078,10 +1089,14 @@ def escalation_decision(
1078
1089
  for em in {e.lower() for e in _EMAIL_RE.findall(blob)}:
1079
1090
  if em not in known:
1080
1091
  return True, "grounding_violation"
1081
- # 2. High-value fact class — always teacher.
1092
+ # 2. High-value fact class — teacher at HIGH_VALUE_SAMPLE_RATE (1.0 = the
1093
+ # original always-teacher gate; lower = student-primary with an elevated
1094
+ # teacher sample on exactly this class — see the config note).
1082
1095
  for f in student_result.get("facts", []):
1083
1096
  if (f.get("category") or "").lower() in HIGH_VALUE_CATEGORIES:
1084
- return True, "high_value_class"
1097
+ if HIGH_VALUE_SAMPLE_RATE >= 1.0 or random.random() < HIGH_VALUE_SAMPLE_RATE:
1098
+ return True, "high_value_class"
1099
+ break # HV but sampled-out → student writes; still eligible for gate 3
1085
1100
  # 3. Random monitoring/active-learning sample (not a quality lever).
1086
1101
  if STUDENT_SAMPLE_RATE > 0 and random.random() < STUDENT_SAMPLE_RATE:
1087
1102
  return True, "random_sample"
@@ -2964,7 +2979,8 @@ async def amain():
2964
2979
  f"cascade ENABLED — student-primary "
2965
2980
  f"(model={STUDENT_MODEL}, endpoint={STUDENT_ENDPOINT or '(unset!)'}, "
2966
2981
  f"sample_rate={STUDENT_SAMPLE_RATE}, "
2967
- f"high_value={sorted(HIGH_VALUE_CATEGORIES)})"
2982
+ f"high_value={sorted(HIGH_VALUE_CATEGORIES)}, "
2983
+ f"hv_sample_rate={HIGH_VALUE_SAMPLE_RATE})"
2968
2984
  )
2969
2985
  if not STUDENT_ENDPOINT:
2970
2986
  log.warning(
@@ -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())