@pentatonic-ai/ai-agent-sdk 0.10.36 → 0.10.38

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.36";
881
+ var VERSION = "0.10.38";
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.36";
850
+ var VERSION = "0.10.38";
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.36",
3
+ "version": "0.10.38",
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",
@@ -37,6 +37,7 @@ import uuid
37
37
  from contextlib import asynccontextmanager
38
38
  from datetime import datetime
39
39
  from typing import Any
40
+ from urllib.parse import urlsplit
40
41
 
41
42
  import httpx
42
43
  import numpy as np
@@ -59,6 +60,14 @@ NV_EMBED_PROVIDER = os.environ.get("NV_EMBED_PROVIDER", "openai") # 'openai' |
59
60
  EMBED_DIM = int(os.environ.get("EMBED_DIM", "4096"))
60
61
  # Window (minutes) over which /health/deep computes the distillation drain rate.
61
62
  HEALTH_DRAIN_WINDOW_MIN = int(os.environ.get("HEALTH_DRAIN_WINDOW_MIN", "5"))
63
+ # GPU/compute-tier endpoints probed by the /health/deep stack-online gauge. The
64
+ # teacher is probed THROUGH its load-balancer (a stable service name that is up
65
+ # even when the fleet is scaled to zero) — see health_deep for why the probe
66
+ # treats a no-upstream 502 as offline. STUDENT_ENDPOINT is the vLLM student box;
67
+ # when unset (not wired into compat's env) the student tier is simply omitted
68
+ # from the gauge rather than counted as down.
69
+ STUDENT_ENDPOINT = os.environ.get("STUDENT_ENDPOINT", "")
70
+ TEACHER_HEALTH_URL = os.environ.get("TEACHER_HEALTH_URL", "http://teacher-lb:8500/v1/models")
62
71
 
63
72
  COLLECTION_NAME = "evidence"
64
73
 
@@ -651,10 +660,36 @@ async def health():
651
660
  return {"status": "healthy", "service": "pme2-compat", "version": "0.1.0"}
652
661
 
653
662
 
663
+ def _models_url(endpoint: str) -> str:
664
+ """Rewrite any endpoint URL to its OpenAI-style /v1/models path — a cheap
665
+ GET the vLLM student/teacher boxes and the embed gateways all answer. Keeps
666
+ only scheme+host+port so it works whether the source URL was /v1/embeddings,
667
+ /v1/chat/completions, etc."""
668
+ p = urlsplit(endpoint)
669
+ return f"{p.scheme}://{p.netloc}/v1/models"
670
+
671
+
672
+ async def _tier_online(url: str, *, healthy_below: int = 600) -> bool:
673
+ """Liveness probe for one compute tier. Online iff the endpoint answers with
674
+ an HTTP status < healthy_below within the timeout. Connection refused /
675
+ timeout / DNS failure → offline.
676
+
677
+ healthy_below defaults to 600 (any HTTP answer = the box is powered on and
678
+ serving). Pass 500 for a tier fronted by an always-up load balancer (the
679
+ teacher LB): with the fleet scaled to zero the LB itself still answers, but
680
+ with a 502 (no healthy upstream) — which we must read as OFFLINE, not "the
681
+ LB replied, so it's up"."""
682
+ try:
683
+ r = await _http.get(url, timeout=3.0)
684
+ return r.status_code < healthy_below
685
+ except Exception:
686
+ return False
687
+
688
+
654
689
  @app.get("/health/deep")
655
690
  async def health_deep():
656
- """Round-trips all three stores + the embed gateway. Slow; do not
657
- use as a docker healthcheck."""
691
+ """Round-trips all three stores + the embed gateway, then computes a
692
+ stack-online gauge. Slow; do not use as a docker healthcheck."""
658
693
  result = {"compat": "ok", "stores": {}}
659
694
 
660
695
  # org-model
@@ -710,6 +745,44 @@ async def health_deep():
710
745
  except Exception as e:
711
746
  result["stores"]["embed_gateway"] = {"status": "error", "error": str(e)}
712
747
 
748
+ # Stack-online gauge: what fraction of the compute stack is powered on RIGHT
749
+ # NOW. Deliberately counts every tier — including the scale-to-zero teacher
750
+ # fleet — so a parked teacher legitimately lowers the %. This is a "what's
751
+ # running" dial, not a pure health signal: the distillation drain/backlog
752
+ # block above says whether a parked teacher is actually a problem. Tiers the
753
+ # store round-trips already assessed are reused (no re-probe); the GPU tiers
754
+ # are probed concurrently for liveness.
755
+ components: dict[str, bool] = {
756
+ "compat": True, # we are the one answering
757
+ "org_model": result["stores"].get("org_model", {}).get("status") == "ok",
758
+ "vector_index": result["stores"].get("vector_index", {}).get("status") == "ok",
759
+ "embed_interactive": result["stores"].get("embed_gateway", {}).get("status") == "ok",
760
+ }
761
+ # GPU/compute tiers not covered by the store probes. (name, url, healthy_below)
762
+ gpu_probes = [("teacher", TEACHER_HEALTH_URL, 500)]
763
+ # Bulk embedder only counts as a distinct tier when it's a separate box.
764
+ if NV_EMBED_URL_BULK and NV_EMBED_URL_BULK != NV_EMBED_URL:
765
+ gpu_probes.append(("embed_bulk", _models_url(NV_EMBED_URL_BULK), 600))
766
+ if STUDENT_ENDPOINT:
767
+ gpu_probes.append(("student", _models_url(STUDENT_ENDPOINT), 600))
768
+ try:
769
+ probe_results = await asyncio.gather(
770
+ *(_tier_online(url, healthy_below=hb) for _, url, hb in gpu_probes)
771
+ )
772
+ for (name, _, _), up in zip(gpu_probes, probe_results):
773
+ components[name] = up
774
+ except Exception as e: # pragma: no cover — probing must never 500 the endpoint
775
+ result["stack_probe_error"] = str(e)
776
+
777
+ online = sum(1 for up in components.values() if up)
778
+ total = len(components)
779
+ result["stack"] = {
780
+ "online_pct": round(100 * online / total, 1) if total else 0.0,
781
+ "online": online,
782
+ "total": total,
783
+ "components": {k: ("up" if up else "down") for k, up in components.items()},
784
+ }
785
+
713
786
  return result
714
787
 
715
788
 
@@ -2646,7 +2646,7 @@ def claim_next_batch(conn: psycopg.Connection) -> list[dict[str, Any]]:
2646
2646
  ) AND attempts < %s
2647
2647
  FOR UPDATE SKIP LOCKED
2648
2648
  )
2649
- RETURNING id, event_id, attempts
2649
+ RETURNING id, event_id, attempts, transient_attempts
2650
2650
  """,
2651
2651
  (WORKER_ID, CLAIM_TTL_SEC, MAX_ATTEMPTS, MAX_ATTEMPTS,
2652
2652
  BATCH_SIZE, BATCH_SIZE, MAX_ATTEMPTS),
@@ -2713,6 +2713,68 @@ def _attempts_exhausted(attempts: int) -> bool:
2713
2713
  return attempts + 1 >= MAX_ATTEMPTS
2714
2714
 
2715
2715
 
2716
+ # ── transient vs genuine failures ─────────────────────────────────────
2717
+ # Transient = infra hiccups that resolve on their own: the teacher timing out
2718
+ # under load (ReadTimeout) or the teacher-lb returning 502/503/504 while the
2719
+ # fleet is cold-starting from zero. These must NOT burn the genuine `attempts`
2720
+ # budget (3) — 3 quick ReadTimeouts used to march a row to `failed`, and failed
2721
+ # rows are never requeued, so the extraction was silently lost. Instead they get
2722
+ # their own larger budget (MAX_TRANSIENT_ATTEMPTS) on the `transient_attempts`
2723
+ # counter (migration 013), leaving `attempts` untouched so the row stays
2724
+ # claimable (`attempts < MAX_ATTEMPTS` in claim_next_batch). ReadTimeouts are
2725
+ # self-spaced by LLM_TIMEOUT_SEC and the fair-share round-robin spreads re-claims
2726
+ # across the backlog, so this does not hot-loop; MAX_TRANSIENT_ATTEMPTS bounds a
2727
+ # genuine poison chunk (one that times out even on a healthy multi-box fleet).
2728
+ MAX_TRANSIENT_ATTEMPTS = int(os.environ.get("MAX_TRANSIENT_ATTEMPTS", "12"))
2729
+ _TRANSIENT_MARKERS = (
2730
+ "ReadTimeout",
2731
+ "TimeoutException",
2732
+ "ConnectTimeout",
2733
+ "ConnectError",
2734
+ "PoolTimeout",
2735
+ "502",
2736
+ "503",
2737
+ "504",
2738
+ "Bad Gateway",
2739
+ "Gateway Time-out",
2740
+ "Service Unavailable",
2741
+ "Temporarily Unavailable",
2742
+ )
2743
+
2744
+
2745
+ def _is_transient(err: str) -> bool:
2746
+ """True if `err` looks like a recoverable infra hiccup (teacher timeout /
2747
+ LB gateway error) rather than a genuine extraction/DB failure."""
2748
+ return any(m in err for m in _TRANSIENT_MARKERS)
2749
+
2750
+
2751
+ def requeue_transient(
2752
+ conn: psycopg.Connection, queue_id: int, transient_attempts: int, error: str
2753
+ ) -> None:
2754
+ """Recoverable INFRA failure. Release the claim back to pending WITHOUT
2755
+ touching `attempts` (preserves the genuine-failure budget), counting instead
2756
+ against `transient_attempts`. Once that budget is exhausted the row IS marked
2757
+ failed — a chunk that times out this many times is genuinely unprocessable,
2758
+ not a passing hiccup."""
2759
+ if transient_attempts + 1 >= MAX_TRANSIENT_ATTEMPTS:
2760
+ mark_failed(conn, queue_id, f"transient-exhausted: {error}")
2761
+ return
2762
+ with conn.cursor() as cur:
2763
+ cur.execute(
2764
+ """
2765
+ UPDATE distillation_queue SET
2766
+ status = 'pending',
2767
+ claimed_by = NULL,
2768
+ claimed_at = NULL,
2769
+ claim_expires_at = NULL,
2770
+ transient_attempts = transient_attempts + 1,
2771
+ last_error = %s
2772
+ WHERE id = %s
2773
+ """,
2774
+ (error[:1024], queue_id),
2775
+ )
2776
+
2777
+
2716
2778
  # --------------------------------------------------------------------
2717
2779
  # Main loop
2718
2780
  # --------------------------------------------------------------------
@@ -2924,7 +2986,11 @@ async def _run_teacher(
2924
2986
  log.warning(
2925
2987
  f"extraction failed queue_id={queue_id} attempts={attempts}: {err}"
2926
2988
  )
2927
- if _attempts_exhausted(attempts):
2989
+ if _is_transient(err):
2990
+ requeue_transient(
2991
+ conn, queue_id, item.get("transient_attempts", 0), err
2992
+ )
2993
+ elif _attempts_exhausted(attempts):
2928
2994
  mark_failed(conn, queue_id, err)
2929
2995
  else:
2930
2996
  release_claim(conn, queue_id, err)
@@ -3022,8 +3088,13 @@ def _apply_extraction(
3022
3088
  f"db upsert failed queue_id={queue_id} attempts={attempts}: {err}"
3023
3089
  )
3024
3090
  # The transaction rolled back; the claim bookkeeping runs on the
3025
- # now-autocommit connection so it persists independently.
3026
- if _attempts_exhausted(attempts):
3091
+ # now-autocommit connection so it persists independently. A transient DB
3092
+ # blip (connection/pool timeout) requeues without burning the genuine
3093
+ # budget; real errors (UniqueViolation, schema mismatch) don't match
3094
+ # _is_transient and take the normal attempts path.
3095
+ if _is_transient(err):
3096
+ requeue_transient(conn, queue_id, item.get("transient_attempts", 0), err)
3097
+ elif _attempts_exhausted(attempts):
3027
3098
  mark_failed(conn, queue_id, err)
3028
3099
  else:
3029
3100
  release_claim(conn, queue_id, err)
@@ -0,0 +1,27 @@
1
+ -- 013 — separate the TRANSIENT retry budget from the genuine-failure budget.
2
+ --
3
+ -- The distiller shares one `attempts` counter for every failure, capped at
4
+ -- MAX_ATTEMPTS (3) and enforced by the claim query's `attempts < MAX_ATTEMPTS`
5
+ -- filter. That conflates two very different failures:
6
+ -- * genuine — parse error, NULL-subject fact, schema mismatch: real, should
7
+ -- give up after a few tries.
8
+ -- * transient — teacher ReadTimeout under load, teacher-lb 502/503/504 during
9
+ -- a scale-from-zero window: infra hiccups that resolve on their
10
+ -- own once the fleet catches up / a box finishes booting.
11
+ --
12
+ -- Because they shared a counter, a transient hiccup burned the genuine budget:
13
+ -- 3 quick ReadTimeouts → `failed`, and since failed rows are never requeued the
14
+ -- extraction was silently LOST (measured 154 ReadTimeout losses in a single day
15
+ -- during a bulk-import whale, 2026-07-14).
16
+ --
17
+ -- This adds a dedicated `transient_attempts` counter. worker.py routes transient
18
+ -- errors here (incrementing THIS, leaving `attempts` untouched so the row stays
19
+ -- claimable — `attempts` remains < MAX_ATTEMPTS) up to MAX_TRANSIENT_ATTEMPTS,
20
+ -- after which the row is marked failed like any genuinely stuck work. Genuine
21
+ -- errors keep using `attempts` exactly as before.
22
+ --
23
+ -- Metadata-only on PostgreSQL (constant DEFAULT, no table rewrite) — safe to run
24
+ -- online against the ~250k-row prod table. Idempotent.
25
+
26
+ ALTER TABLE distillation_queue
27
+ ADD COLUMN IF NOT EXISTS transient_attempts integer NOT NULL DEFAULT 0;