@pentatonic-ai/ai-agent-sdk 0.10.28 → 0.10.30

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.28";
881
+ var VERSION = "0.10.30";
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.28";
850
+ var VERSION = "0.10.30";
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.28",
3
+ "version": "0.10.30",
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",
@@ -131,6 +131,28 @@ STUDENT_MAX_TOKENS = int(os.environ.get("STUDENT_MAX_TOKENS", "768"))
131
131
  # the box's KV-cache headroom. Do NOT raise CONCURRENT_LLM_CALLS to fix student
132
132
  # throughput — that also raises teacher 27B concurrency (OOM risk).
133
133
  STUDENT_CONCURRENT_CALLS = int(os.environ.get("STUDENT_CONCURRENT_CALLS", "48"))
134
+ # How many claimed batches the worker processes CONCURRENTLY. The prior loop
135
+ # claimed a batch, awaited it end-to-end (student gather → straggler tail →
136
+ # teacher tail), and only then claimed the next — a hard barrier. Wall-clock
137
+ # profiling of the 25k-backlog incident (2026-07-08) showed the student L40S
138
+ # burst to Running:50 for ~10s then sit at Running:1 for ~3min draining a
139
+ # straggler tail while the (LB'd) teacher fleet idled — the GPUs were starved by
140
+ # the loop, not by capacity. Overlapping N batches, all sharing ONE student pool
141
+ # and ONE teacher pool (the shared semaphores below), keeps those pools
142
+ # continuously full: batch B's fresh student calls take the slots batch A's
143
+ # stragglers free. Total LLM concurrency is still bounded by the shared sems
144
+ # (STUDENT_CONCURRENT_CALLS / CONCURRENT_LLM_CALLS) REGARDLESS of this value —
145
+ # this only controls how many independent claim→apply pipelines feed them (each
146
+ # on its own PG connection; applies within a batch stay sequential, so one conn
147
+ # per batch is never touched concurrently). Raise to feed the pools harder; the
148
+ # sems, not this, cap GPU load.
149
+ MAX_INFLIGHT_BATCHES = int(os.environ.get("MAX_INFLIGHT_BATCHES", "4"))
150
+ # Process-global shared concurrency pools, created once inside amain's event
151
+ # loop (NOT per-batch — per-batch pools are what caused the burst-then-idle).
152
+ # All in-flight batches acquire from these, so the student/teacher GPUs see one
153
+ # continuous stream of calls instead of per-batch bursts. None until amain runs.
154
+ _STUDENT_SEM: "asyncio.Semaphore | None" = None
155
+ _TEACHER_SEM: "asyncio.Semaphore | None" = None
134
156
  # Fraction of student-passing events ALSO sent to the teacher — NOT a quality
135
157
  # lever, this is the monitoring + active-learning sample (drift detection and
136
158
  # fresh teacher-gold on live traffic). Default conservative-ish 5%.
@@ -2662,7 +2684,11 @@ async def _process_cascade(
2662
2684
  # STUDENT_CONCURRENT_CALLS, not CONCURRENT_LLM_CALLS: the student is a cheap
2663
2685
  # single-event 4B call that batches far higher than the teacher's 27B
2664
2686
  # batch-calls (see the config note). Reusing the teacher knob starved it.
2665
- sem = asyncio.Semaphore(STUDENT_CONCURRENT_CALLS)
2687
+ # SHARED across all in-flight batches (see MAX_INFLIGHT_BATCHES) so the
2688
+ # student pool stays continuously full — a per-batch semaphore burst-then-
2689
+ # idled the GPU. amain initialises it before any batch runs.
2690
+ sem = _STUDENT_SEM
2691
+ assert sem is not None, "amain must initialise _STUDENT_SEM before processing"
2666
2692
 
2667
2693
  async def _student(item):
2668
2694
  async with sem:
@@ -2719,8 +2745,12 @@ async def _run_teacher(
2719
2745
  chunks.append((chunk_items, chunk_events))
2720
2746
 
2721
2747
  # Bound parallelism with a semaphore — gather only waits for slots,
2722
- # not for everything to be ready at once.
2723
- sem = asyncio.Semaphore(CONCURRENT_LLM_CALLS)
2748
+ # not for everything to be ready at once. SHARED across all in-flight
2749
+ # batches so the teacher fleet (behind the LB) sees a continuous stream and
2750
+ # its total concurrency stays capped at CONCURRENT_LLM_CALLS regardless of
2751
+ # how many batches are escalating at once. amain initialises it.
2752
+ sem = _TEACHER_SEM
2753
+ assert sem is not None, "amain must initialise _TEACHER_SEM before processing"
2724
2754
 
2725
2755
  async def run_one(chunk_items, chunk_events):
2726
2756
  async with sem:
@@ -2805,70 +2835,91 @@ def _apply_extraction(
2805
2835
  det_ents, det_rels = structured_graph_elements(arena, event.get("attributes"))
2806
2836
 
2807
2837
  try:
2808
- name_to_id = upsert_entities(
2809
- conn, arena, event_id, participant_set, disclosure, ents + det_ents,
2810
- event_time, event.get("attributes"),
2811
- )
2812
- n_facts = upsert_facts(
2813
- conn, arena, event_id, participant_set, disclosure, facts, name_to_id,
2814
- event_time, due_at, src,
2815
- )
2816
- n_rels = upsert_relationships(
2817
- conn, arena, event_id, participant_set, disclosure, rels + det_rels, name_to_id,
2818
- event_time,
2819
- )
2820
- mark_done(conn, queue_id)
2821
- log.info(
2822
- f"completed queue_id={queue_id} event_id={event_id} producer={producer} "
2823
- f"entities={len(name_to_id)} facts={n_facts} relationships={n_rels}"
2824
- f" det_edges={len(det_rels)}"
2825
- + (f" llm_ms={llm_ms:.0f}" if not stub_mode and llm_ms else "")
2826
- )
2827
- # Trace logging — best-effort. ONLY the teacher produces training gold;
2828
- # logging student output would train the student on itself.
2829
- if DISTILL_TRACE_ENABLED and not stub_mode and producer == "teacher":
2830
- try:
2831
- _insert_trace(
2832
- conn,
2833
- event_id=event_id,
2834
- user_prompt=build_event_block(local_idx, event),
2835
- raw_response=result.get("raw_slice", ""),
2836
- llm_chunk_ms=llm_ms,
2837
- )
2838
- except Exception as trace_exc:
2839
- log.warning(
2840
- f"trace insert failed queue_id={queue_id} "
2841
- f"event_id={event_id}: {trace_exc}"
2842
- )
2843
- # Cascade ledger — best-effort, records which producer wrote this
2844
- # event's rows (only when the cascade is on).
2845
- if CASCADE_ENABLED:
2846
- try:
2847
- _record_distillation(
2848
- conn,
2849
- event_id=event_id,
2850
- producer=producer,
2851
- llm_model=LLM_MODEL if producer == "teacher" else STUDENT_MODEL,
2852
- escalated=escalated,
2853
- escalate_reason=escalate_reason,
2854
- )
2855
- except Exception as ledger_exc:
2856
- log.warning(
2857
- f"ledger insert failed queue_id={queue_id} "
2858
- f"event_id={event_id}: {ledger_exc}"
2859
- )
2860
- return True
2838
+ # ONE transaction per event. The connection is autocommit (amain), so
2839
+ # without this each statement fsync-commits on its own — ~20 commits per
2840
+ # event (entity resolution SELECTs/locks/upserts + facts + rels +
2841
+ # mark_done), which is the round-trip bottleneck under a backlog: the
2842
+ # graph PG sits idle between the worker's serial commits (profiled
2843
+ # 2026-07-08 39 conns idle, 1 active). Wrapping commits once per event.
2844
+ #
2845
+ # NB: this also makes upsert_entities' `pg_advisory_xact_lock` actually
2846
+ # span the event's resolution. Under autocommit each lock released after
2847
+ # its own single-statement txn effectively a NO-OP — so the
2848
+ # per-surface-form anti-fragmentation lock never protected the
2849
+ # SELECT-overlap→INSERT window. Now it holds for the whole event: correct
2850
+ # (the lock finally does its job), but it serialises concurrent workers
2851
+ # that touch the same surface form. Load-test before trusting on prod.
2852
+ with conn.transaction():
2853
+ name_to_id = upsert_entities(
2854
+ conn, arena, event_id, participant_set, disclosure, ents + det_ents,
2855
+ event_time, event.get("attributes"),
2856
+ )
2857
+ n_facts = upsert_facts(
2858
+ conn, arena, event_id, participant_set, disclosure, facts, name_to_id,
2859
+ event_time, due_at, src,
2860
+ )
2861
+ n_rels = upsert_relationships(
2862
+ conn, arena, event_id, participant_set, disclosure, rels + det_rels, name_to_id,
2863
+ event_time,
2864
+ )
2865
+ mark_done(conn, queue_id)
2861
2866
  except Exception as exc:
2862
2867
  err = f"{type(exc).__name__}: {exc}"
2863
2868
  log.warning(
2864
2869
  f"db upsert failed queue_id={queue_id} attempts={attempts}: {err}"
2865
2870
  )
2871
+ # The transaction rolled back; the claim bookkeeping runs on the
2872
+ # now-autocommit connection so it persists independently.
2866
2873
  if _attempts_exhausted(attempts):
2867
2874
  mark_failed(conn, queue_id, err)
2868
2875
  else:
2869
2876
  release_claim(conn, queue_id, err)
2870
2877
  return False
2871
2878
 
2879
+ log.info(
2880
+ f"completed queue_id={queue_id} event_id={event_id} producer={producer} "
2881
+ f"entities={len(name_to_id)} facts={n_facts} relationships={n_rels}"
2882
+ f" det_edges={len(det_rels)}"
2883
+ + (f" llm_ms={llm_ms:.0f}" if not stub_mode and llm_ms else "")
2884
+ )
2885
+ # Best-effort side-writes, AFTER the graph commit and OUTSIDE the transaction:
2886
+ # a failure here must not roll back the committed graph rows, and an error
2887
+ # inside the txn would poison it (a swallowed error leaves the txn aborted).
2888
+ # Trace: ONLY the teacher produces training gold; logging student output would
2889
+ # train the student on itself.
2890
+ if DISTILL_TRACE_ENABLED and not stub_mode and producer == "teacher":
2891
+ try:
2892
+ _insert_trace(
2893
+ conn,
2894
+ event_id=event_id,
2895
+ user_prompt=build_event_block(local_idx, event),
2896
+ raw_response=result.get("raw_slice", ""),
2897
+ llm_chunk_ms=llm_ms,
2898
+ )
2899
+ except Exception as trace_exc:
2900
+ log.warning(
2901
+ f"trace insert failed queue_id={queue_id} "
2902
+ f"event_id={event_id}: {trace_exc}"
2903
+ )
2904
+ # Cascade ledger — best-effort, records which producer wrote this
2905
+ # event's rows (only when the cascade is on).
2906
+ if CASCADE_ENABLED:
2907
+ try:
2908
+ _record_distillation(
2909
+ conn,
2910
+ event_id=event_id,
2911
+ producer=producer,
2912
+ llm_model=LLM_MODEL if producer == "teacher" else STUDENT_MODEL,
2913
+ escalated=escalated,
2914
+ escalate_reason=escalate_reason,
2915
+ )
2916
+ except Exception as ledger_exc:
2917
+ log.warning(
2918
+ f"ledger insert failed queue_id={queue_id} "
2919
+ f"event_id={event_id}: {ledger_exc}"
2920
+ )
2921
+ return True
2922
+
2872
2923
 
2873
2924
  async def amain():
2874
2925
  log.info(
@@ -2896,20 +2947,65 @@ async def amain():
2896
2947
  if stub_mode:
2897
2948
  log.warning("LLM_ENDPOINT not set — running in stub mode (no extraction).")
2898
2949
 
2950
+ # Shared concurrency pools — created HERE, inside the running event loop, so
2951
+ # ONE student pool and ONE teacher pool are shared by every in-flight batch
2952
+ # (see MAX_INFLIGHT_BATCHES). Per-batch pools burst the GPU to Running:~50
2953
+ # then idled it at Running:1 through each batch's straggler/teacher tail; one
2954
+ # shared pool stays continuously full across overlapping batches.
2955
+ global _STUDENT_SEM, _TEACHER_SEM
2956
+ _STUDENT_SEM = asyncio.Semaphore(STUDENT_CONCURRENT_CALLS)
2957
+ _TEACHER_SEM = asyncio.Semaphore(CONCURRENT_LLM_CALLS)
2958
+ log.info(
2959
+ f"pipelined loop: max_inflight_batches={MAX_INFLIGHT_BATCHES}, "
2960
+ f"student_pool={STUDENT_CONCURRENT_CALLS}, teacher_pool={CONCURRENT_LLM_CALLS}"
2961
+ )
2962
+
2899
2963
  # Single async client across the loop. The httpx default limits
2900
- # (max_connections=100, max_keepalive=20) easily cover BATCH_SIZE
2964
+ # (max_connections=100, max_keepalive=20) easily cover the shared-pool
2901
2965
  # concurrent inflight LLM calls.
2902
2966
  async with httpx.AsyncClient(timeout=LLM_TIMEOUT_SEC) as http:
2967
+
2968
+ async def _run_one_batch() -> int:
2969
+ """Claim + fully process one batch on a DEDICATED connection.
2970
+ Returns the number of items claimed (0 = queue empty). Applies within
2971
+ a batch happen sequentially (after each gather), so a batch's
2972
+ connection is never touched concurrently — one conn per batch is
2973
+ safe. The `with` closes the conn on both success and exception; a
2974
+ crash leaves that batch's items claimed until CLAIM_TTL_SEC expires
2975
+ and they are reclaimed (unchanged from the prior loop)."""
2976
+ with psycopg.connect(PG_DSN, autocommit=True) as conn:
2977
+ items = claim_next_batch(conn)
2978
+ if not items:
2979
+ return 0
2980
+ log.info(f"claimed {len(items)} item(s)")
2981
+ await process_batch(http, conn, items, stub_mode)
2982
+ return len(items)
2983
+
2984
+ # Keep up to MAX_INFLIGHT_BATCHES independent claim→apply pipelines
2985
+ # running at once. Their student/teacher calls all draw from the shared
2986
+ # pools above, so while one batch drains its slow tail the others keep
2987
+ # the GPUs fed. Total GPU concurrency is capped by the sems, NOT by this
2988
+ # count.
2989
+ inflight: set[asyncio.Task] = set()
2903
2990
  while True:
2904
2991
  try:
2905
- with psycopg.connect(PG_DSN, autocommit=True) as conn:
2906
- items = claim_next_batch(conn)
2907
- if not items:
2908
- await asyncio.sleep(POLL_INTERVAL_SEC)
2909
- continue
2910
-
2911
- log.info(f"claimed {len(items)} item(s)")
2912
- await process_batch(http, conn, items, stub_mode)
2992
+ while len(inflight) < MAX_INFLIGHT_BATCHES:
2993
+ inflight.add(asyncio.create_task(_run_one_batch()))
2994
+ done, inflight = await asyncio.wait(
2995
+ inflight, return_when=asyncio.FIRST_COMPLETED
2996
+ )
2997
+ claimed_any = False
2998
+ for t in done:
2999
+ try:
3000
+ if t.result() > 0:
3001
+ claimed_any = True
3002
+ except Exception as exc:
3003
+ log.error(f"batch task error: {exc}")
3004
+ # All finished batches were empty claims ⇒ queue drained; back
3005
+ # off before re-claiming so idle workers don't hot-spin. Under a
3006
+ # real backlog every claim returns items and this never sleeps.
3007
+ if not claimed_any:
3008
+ await asyncio.sleep(POLL_INTERVAL_SEC)
2913
3009
  except Exception as exc:
2914
3010
  log.error(f"worker loop error: {exc}")
2915
3011
  await asyncio.sleep(POLL_INTERVAL_SEC * 2)