@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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
)
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
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
|
|
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
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
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)
|