@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 +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/packages/memory-engine-v2/compat/server.py +75 -2
- package/packages/memory-engine-v2/extractor-async/worker.py +75 -4
- package/packages/memory-engine-v2/org-model/migrations/013_distillation_transient_attempts.sql +27 -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.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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
package/packages/memory-engine-v2/org-model/migrations/013_distillation_transient_attempts.sql
ADDED
|
@@ -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;
|