@pentatonic-ai/ai-agent-sdk 0.10.37 → 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.37";
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.37";
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.37",
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",
@@ -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;